mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Merge origin/main into brennanb2025/fix-modal-recheck-action
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+83
-17
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
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
|
||||
} from './mobile-web-app-route-manifest.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
|
||||
},
|
||||
{
|
||||
// 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 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' },
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildMobileWebAppBundle({ appDir, outDir = defaultOutDir } = {}) {
|
||||
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
|
||||
})
|
||||
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,608 @@
|
||||
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,
|
||||
routeChunkNames
|
||||
} from './build-mobile-web-app-bundle.mjs'
|
||||
import {
|
||||
MOBILE_WEB_APP_ROUTE_ROOT,
|
||||
ROUTE_SOURCE_LOADERS,
|
||||
collectMobileWebAppRouteKeys,
|
||||
collectMobileWebAppRoutes
|
||||
} 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 {
|
||||
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 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') })
|
||||
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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,24 @@ 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
|
||||
}) {
|
||||
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))
|
||||
|
||||
@@ -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,342 @@
|
||||
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'
|
||||
|
||||
// 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
|
||||
|
||||
/**
|
||||
* 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 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()
|
||||
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(
|
||||
(bytes) => {
|
||||
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.
|
||||
*/
|
||||
async function openPage() {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function render(route, awaitText) {
|
||||
const opened = await openPage()
|
||||
await opened.page.goto(`${origin}${route}`, { waitUntil: 'load' })
|
||||
await waitForRoute(opened, route, awaitText)
|
||||
const text = await opened.page.evaluate(() => document.body.innerText)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 } = await render(HOST_ROUTE, 'Host not found')
|
||||
expect(cspErrors).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
// app/h/[hostId]/index.tsx: the placeholder client knows no host, so the list paints its
|
||||
// not-found state. Only that route's own component produces this string.
|
||||
expect(text).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 } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
|
||||
expect(cspErrors).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
// 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("fetches the next route's chunks on a client-side navigation", async () => {
|
||||
const opened = await openPage()
|
||||
const { page, errors, scripts } = opened
|
||||
await page.goto(`${origin}${HOST_ROUTE}`, { waitUntil: 'load' })
|
||||
await waitForRoute(opened, HOST_ROUTE, 'Host not found')
|
||||
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,185 @@
|
||||
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 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', () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 64m">
|
||||
<title>downloads: 64m</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">64m</text>
|
||||
<text x="90" y="14">64m</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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { loadMobileWebShellEnabled } from '../../../src/storage/preferences'
|
||||
import { colors } from '../../../src/theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The hybrid shell route, dark behind a development-only flag.
|
||||
*
|
||||
* The only caller of `loadMobileWebShellEnabled`. 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, setEnabled] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
void loadMobileWebShellEnabled().then((value) => {
|
||||
if (!stale) {
|
||||
setEnabled(value)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
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 ?? ''}`} />
|
||||
}
|
||||
return <MobileWebShellScreen hostId={hostId} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
|
||||
/**
|
||||
* Web sibling for the hybrid shell route. This page is what that route's WebView displays, so the
|
||||
* shell has nowhere to nest here; the 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 MobileWebShellRoute() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
return <Redirect href={`/h/${hostId ?? ''}`} />
|
||||
}
|
||||
@@ -54,6 +54,8 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
|
||||
/>
|
||||
<Stack.Screen name="[hostId]/review/[worktreeId]" options={{ title: 'Changes' }} />
|
||||
<Stack.Screen name="[hostId]/pr/[worktreeId]" options={{ title: 'Pull Request' }} />
|
||||
{/* Dev-flag only: redirects to the host screen unless the hybrid shell flag is on. */}
|
||||
<Stack.Screen name="[hostId]/web" options={{ title: 'Workspace' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from 'expo-router'
|
||||
import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-probe-row'
|
||||
import { MobileWebShellDevRow } from '../src/diagnostics/mobile-web-shell-dev-row'
|
||||
import { TroubleshootView } from '../src/diagnostics/troubleshoot-view'
|
||||
import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics'
|
||||
|
||||
@@ -21,7 +22,14 @@ export default function NativeTroubleshootRoute() {
|
||||
runDiagnostics={() => void runDiagnostics()}
|
||||
onBack={() => router.back()}
|
||||
onConnectionLog={() => router.push('/connection-log')}
|
||||
developerRow={isDevelopmentBuild ? <MobileWebBundleProbeRow /> : null}
|
||||
developerRow={
|
||||
isDevelopmentBuild ? (
|
||||
<>
|
||||
<MobileWebBundleProbeRow />
|
||||
<MobileWebShellDevRow />
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build/
|
||||
@@ -0,0 +1,25 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.orcamobilewebshell'
|
||||
version = '0.0.1'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(':expo-modules-core').projectDir.absolutePath, 'ExpoModulesCorePlugin.gradle')
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
useDefaultAndroidSdkVersions()
|
||||
|
||||
android {
|
||||
namespace 'expo.modules.orcamobilewebshell'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Already on the APK classpath at this exact version via react-native-webview
|
||||
// (node_modules/react-native-webview/android/gradle.properties), so this adds no artifact.
|
||||
implementation 'androidx.webkit:webkit:1.14.0'
|
||||
// The android.jar used by JVM unit tests stubs org.json, so the real parser has to be on the
|
||||
// test classpath or every manifest check would read null.
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/**
|
||||
* The prop triple a load was started for, and the only thing that decides whether the next prop
|
||||
* commit re-enters. The same rule as the Swift copy.
|
||||
*
|
||||
* Recording the props rather than the outcome is what makes a failure converge. A guard that reads
|
||||
* whether the bridge actually installed never agrees with a prop that is true but could not be
|
||||
* honoured — a malformed session id, an unreadable generation, a WebView too old for the listener —
|
||||
* so every later commit re-enters, resets the machine, and re-emits loading then failed forever.
|
||||
*/
|
||||
internal class MobileWebShellAppliedProps(
|
||||
private val generationDirectory: String,
|
||||
val sessionId: String,
|
||||
private val bridgeEnabled: Boolean
|
||||
) {
|
||||
/**
|
||||
* Field by field rather than a data class: a generated `equals` would grow with any field added
|
||||
* to the record, which is how a prop nobody meant to be a reload becomes one.
|
||||
*/
|
||||
fun matches(other: MobileWebShellAppliedProps): Boolean =
|
||||
generationDirectory == other.generationDirectory &&
|
||||
sessionId == other.sessionId &&
|
||||
bridgeEnabled == other.bridgeEnabled
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/**
|
||||
* The `WebMessageListener` name, which is also the global Chromium injects into the page. iOS
|
||||
* installs a global of the same name, so one page reaches both shells.
|
||||
*/
|
||||
internal const val MOBILE_WEB_SHELL_BRIDGE_OBJECT = "orcaBridge"
|
||||
|
||||
/**
|
||||
* Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract holds
|
||||
* the same ceiling; native is the one that cannot be talked out of it.
|
||||
*/
|
||||
internal const val MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES = 640 * 1024
|
||||
|
||||
internal fun acceptsMobileWebShellBridgeByteCount(byteCount: Int): Boolean =
|
||||
byteCount <= MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES
|
||||
|
||||
/**
|
||||
* Chromium enforces the allowed-origin set before the listener runs, so the origin is not re-checked
|
||||
* here; what is left is the frame. CSP already says `frame-src 'none'`, but the injected object
|
||||
* reaches every same-origin frame, so the shell states the main-frame rule itself rather than
|
||||
* inheriting it from a header a future bundle could need relaxed.
|
||||
*
|
||||
* The document the current props replaced is same-origin whenever only the directory or the bridge
|
||||
* prop changed, and it is alive until the next one commits, so it has to be refused by when it
|
||||
* spoke rather than by where it spoke from.
|
||||
*/
|
||||
internal fun acceptsMobileWebShellBridgeFrame(
|
||||
isMainFrame: Boolean,
|
||||
isStringMessage: Boolean,
|
||||
hasCommittedDocument: Boolean
|
||||
): Boolean = isMainFrame && isStringMessage && hasCommittedDocument
|
||||
|
||||
/**
|
||||
* Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that
|
||||
* learns which messages were dropped learns the cap. The tally is what a test can hold the cap to.
|
||||
*/
|
||||
internal class MobileWebShellBridgeGate {
|
||||
var refusedCount = 0
|
||||
private set
|
||||
|
||||
fun accepts(byteCount: Int): Boolean {
|
||||
if (!acceptsMobileWebShellBridgeByteCount(byteCount)) {
|
||||
refusedCount += 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** What a prop update should do about the listener, decided before any WebView call. */
|
||||
internal enum class MobileWebShellBridgeInstall {
|
||||
/** The prop is false, so nothing is registered and Phase B behaviour is byte-identical. */
|
||||
SKIP,
|
||||
INSTALL,
|
||||
/** The WebView provider is older than `WEB_MESSAGE_LISTENER` (Chromium 88). Terminal. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor is asked as a feature query and never as a version string: the query is the capability.
|
||||
* An unsupported provider only matters when the bridge was asked for, so the enabled check comes
|
||||
* first — with the prop false the shell must load on a WebView the bridge could not run on.
|
||||
*/
|
||||
internal fun mobileWebShellBridgeInstall(
|
||||
bridgeEnabled: Boolean,
|
||||
isListenerSupported: Boolean
|
||||
): MobileWebShellBridgeInstall = when {
|
||||
!bridgeEnabled -> MobileWebShellBridgeInstall.SKIP
|
||||
isListenerSupported -> MobileWebShellBridgeInstall.INSTALL
|
||||
else -> MobileWebShellBridgeInstall.UNAVAILABLE
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/**
|
||||
* Sent as a response header on the document and nowhere else: a served document must never carry
|
||||
* its own policy, so there is no meta tag to find and no bundle change that can relax it. Kept in
|
||||
* step with the iOS copy.
|
||||
*/
|
||||
internal val MOBILE_WEB_SHELL_CSP = listOf(
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
// React Native Web 0.21.2 injects its stylesheet at runtime with no nonce support, so the
|
||||
// Phase C page cannot paint under 'self' alone (measured: the render check under this exact
|
||||
// header). This relaxes styling only; script-src 'self' is untouched.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self'",
|
||||
"font-src 'none'",
|
||||
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
|
||||
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the
|
||||
// fence for fetch and XMLHttpRequest; the document-start script covers only the two things the
|
||||
// native layer cannot see.
|
||||
"connect-src 'self'",
|
||||
"media-src 'none'",
|
||||
"object-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"worker-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
).joinToString("; ")
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val MOBILE_WEB_SHELL_MANIFEST_NAME = "manifest.json"
|
||||
private const val MOBILE_WEB_SHELL_MANIFEST_CONTENT_TYPE = "application/json"
|
||||
private const val MOBILE_WEB_SHELL_SCHEMA_VERSION = 1
|
||||
private const val MOBILE_WEB_SHELL_ENTRYPOINT = "index.html"
|
||||
private const val MOBILE_WEB_SHELL_MAX_ASSETS = 256
|
||||
private const val MOBILE_WEB_SHELL_MAX_ASSET_PATH_LENGTH = 255
|
||||
private const val MOBILE_WEB_SHELL_MAX_CONTENT_TYPE_LENGTH = 128
|
||||
|
||||
internal data class MobileWebShellAsset(val file: File, val contentType: String)
|
||||
|
||||
/**
|
||||
* The served surface of one activated generation: a request path to file map, built once from the
|
||||
* manifest before anything loads. Serving is a lookup in this map and never a path join at request
|
||||
* time, so "not in the manifest" is a refusal by construction rather than by sanitiser.
|
||||
*
|
||||
* Asset bytes are not re-hashed here. The TypeScript store verified every byte against the manifest
|
||||
* before the activating rename, and the directory path is one the app owns and the page can never
|
||||
* influence.
|
||||
*/
|
||||
internal class MobileWebShellGeneration private constructor(
|
||||
val entries: Map<String, MobileWebShellAsset>
|
||||
) {
|
||||
companion object {
|
||||
fun load(directoryPath: String): MobileWebShellGeneration? {
|
||||
if (!directoryPath.startsWith("/")) return null
|
||||
val directory = File(directoryPath)
|
||||
val manifest = runCatching {
|
||||
File(directory, MOBILE_WEB_SHELL_MANIFEST_NAME).readText(Charsets.UTF_8)
|
||||
}.getOrNull() ?: return null
|
||||
return make(manifest, directory)
|
||||
}
|
||||
|
||||
fun make(manifestJson: String, directory: File): MobileWebShellGeneration? {
|
||||
val root = runCatching { JSONObject(manifestJson) }.getOrNull() ?: return null
|
||||
// opt, not optInt: optInt coerces the string "1" to 1, and the contract pins a number.
|
||||
if (root.opt("schemaVersion") != MOBILE_WEB_SHELL_SCHEMA_VERSION) return null
|
||||
if (root.opt("entrypoint") != MOBILE_WEB_SHELL_ENTRYPOINT) return null
|
||||
val assets = root.opt("assets")
|
||||
if (assets !is JSONArray) return null
|
||||
if (assets.length() == 0 || assets.length() > MOBILE_WEB_SHELL_MAX_ASSETS) return null
|
||||
|
||||
val entries = mutableMapOf<String, MobileWebShellAsset>()
|
||||
for (index in 0 until assets.length()) {
|
||||
val asset = assets.opt(index)
|
||||
if (asset !is JSONObject) return null
|
||||
val path = asset.opt("path")
|
||||
val contentType = asset.opt("contentType")
|
||||
if (path !is String || !isServableAssetPath(path)) return null
|
||||
if (contentType !is String || !isServableContentType(contentType)) return null
|
||||
entries["/$path"] = MobileWebShellAsset(File(directory, path), contentType)
|
||||
}
|
||||
// Removed, not copied: the document answers at "/" and nowhere else, so the one response that
|
||||
// carries the policy header is the only way to reach those bytes.
|
||||
val document = entries.remove("/$MOBILE_WEB_SHELL_ENTRYPOINT") ?: return null
|
||||
entries["/"] = document
|
||||
// The manifest is written last and is not part of the content hash, so it is not in `assets`;
|
||||
// the bootstrap page still reads it from its own origin.
|
||||
entries["/$MOBILE_WEB_SHELL_MANIFEST_NAME"] = MobileWebShellAsset(
|
||||
File(directory, MOBILE_WEB_SHELL_MANIFEST_NAME),
|
||||
MOBILE_WEB_SHELL_MANIFEST_CONTENT_TYPE
|
||||
)
|
||||
return MobileWebShellGeneration(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-checked here rather than trusted: the schema that pins this shape is on the other side of
|
||||
* a file the native layer cannot see change.
|
||||
*/
|
||||
fun isServableAssetPath(path: String): Boolean {
|
||||
if (path.isEmpty() || path.toByteArray(Charsets.UTF_8).size > MOBILE_WEB_SHELL_MAX_ASSET_PATH_LENGTH) {
|
||||
return false
|
||||
}
|
||||
return path.split('/').all { segment ->
|
||||
segment.isNotEmpty() &&
|
||||
segment != "." &&
|
||||
segment != ".." &&
|
||||
segment.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '.' || it == '_' || it == '-' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This value becomes a response header, so it must not be able to carry a second header or a
|
||||
* parameter we did not intend. One lowercase type, one optional charset: the manifest
|
||||
* contract's only accepted spelling.
|
||||
*/
|
||||
fun isServableContentType(contentType: String): Boolean {
|
||||
if (contentType.isEmpty() ||
|
||||
contentType.toByteArray(Charsets.UTF_8).size > MOBILE_WEB_SHELL_MAX_CONTENT_TYPE_LENGTH
|
||||
) {
|
||||
return false
|
||||
}
|
||||
var type = contentType
|
||||
val separator = contentType.indexOf("; charset=")
|
||||
if (separator >= 0) {
|
||||
val charset = contentType.substring(separator + "; charset=".length)
|
||||
if (charset.isEmpty()) return false
|
||||
if (!charset.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }) return false
|
||||
type = contentType.substring(0, separator)
|
||||
}
|
||||
val halves = type.split('/')
|
||||
if (halves.size != 2) return false
|
||||
return halves.all(::isMimeToken)
|
||||
}
|
||||
|
||||
private fun isMimeToken(token: String): Boolean {
|
||||
val first = token.firstOrNull() ?: return false
|
||||
if (!(first in 'a'..'z' || first in '0'..'9')) return false
|
||||
return token.all { it in 'a'..'z' || it in '0'..'9' || it == '.' || it == '+' || it == '-' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `WebResourceResponse` takes the mime type and the encoding separately. */
|
||||
internal fun splitMobileWebShellContentType(contentType: String): Pair<String, String?> {
|
||||
val separator = contentType.indexOf("; charset=")
|
||||
if (separator < 0) return contentType to null
|
||||
return contentType.substring(0, separator) to
|
||||
contentType.substring(separator + "; charset=".length)
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/** The wire names the TypeScript parser accepts; a swap here is a silent change of meaning. */
|
||||
internal enum class MobileWebShellFailureReason(val wireName: String) {
|
||||
GENERATION_UNREADABLE("generation-unreadable"),
|
||||
ISOLATION_UNAVAILABLE("isolation-unavailable"),
|
||||
DOCUMENT_LOAD_FAILED("document-load-failed"),
|
||||
RENDER_PROCESS_GONE("render-process-gone")
|
||||
}
|
||||
|
||||
internal data class MobileWebShellLoadEmission(val state: String, val reason: String?) {
|
||||
fun toPayload(): Map<String, Any> = if (reason == null) {
|
||||
mapOf("state" to state)
|
||||
} else {
|
||||
mapOf("state" to state, "reason" to reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a mount is still allowed to report. A failure is terminal: Chromium commits its own error
|
||||
* document after `onReceivedError` returns, and a rule list can fail to compile long after the
|
||||
* generation was already refused, so without this a `ready` or a second reason lands on top of a
|
||||
* failure the caller has already acted on. Consecutive duplicates are dropped as well.
|
||||
*
|
||||
* Pure, and the same rule on both platforms, so a JVM test and a `swiftc` check can hold it. The
|
||||
* two fields a caller reads directly are volatile: Android decides a document failure from
|
||||
* `shouldInterceptRequest`, which Chromium does not run on the UI thread.
|
||||
*/
|
||||
internal class MobileWebShellLoadStateMachine {
|
||||
private var terminal = false
|
||||
private var last: MobileWebShellLoadEmission? = null
|
||||
|
||||
/** Which load this machine is reporting on. Read before deferring work, checked on delivery. */
|
||||
@Volatile
|
||||
var epoch: Int = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether a document under the current prop triple has committed. The document a load replaces
|
||||
* stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the
|
||||
* directory or the bridge prop changed, so without this it passes every origin check and speaks
|
||||
* for a load the caller has already been told is `loading`.
|
||||
*/
|
||||
@Volatile
|
||||
var hasCommittedDocument = false
|
||||
private set
|
||||
|
||||
/** A new prop pair. Nothing else reopens a terminal state: a retry is a remount. */
|
||||
fun reset() {
|
||||
terminal = false
|
||||
last = null
|
||||
epoch += 1
|
||||
documentEnded()
|
||||
}
|
||||
|
||||
fun committed() {
|
||||
if (terminal) return
|
||||
hasCommittedDocument = true
|
||||
}
|
||||
|
||||
/** The committed document is gone: a new load, a failure, or a renderer that died. */
|
||||
fun documentEnded() {
|
||||
hasCommittedDocument = false
|
||||
}
|
||||
|
||||
fun started(): MobileWebShellLoadEmission? = emit(MobileWebShellLoadEmission("loading", null))
|
||||
|
||||
fun finished(): MobileWebShellLoadEmission? = emit(MobileWebShellLoadEmission("ready", null))
|
||||
|
||||
fun failed(reason: MobileWebShellFailureReason): MobileWebShellLoadEmission? {
|
||||
val emission = emit(MobileWebShellLoadEmission("failed", reason.wireName))
|
||||
terminal = true
|
||||
documentEnded()
|
||||
return emission
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure decided during one load and reported after the next one started belongs to neither:
|
||||
* Android has to defer its report past Chromium's error document, and a prop update can land in
|
||||
* between, which would fail the generation that just replaced the one that actually failed.
|
||||
*/
|
||||
fun failedDuring(epoch: Int, reason: MobileWebShellFailureReason): MobileWebShellLoadEmission? =
|
||||
if (epoch != this.epoch) null else failed(reason)
|
||||
|
||||
private fun emit(emission: MobileWebShellLoadEmission): MobileWebShellLoadEmission? {
|
||||
if (terminal || emission == last) return null
|
||||
last = emission
|
||||
return emission
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/**
|
||||
* Whether a navigation is dropped. Only the document URL of the generation currently served is
|
||||
* allowed to load: nothing in the bundle navigates, so anything that tries is either a link the
|
||||
* page opened or a URL the page built, and neither is ours to follow.
|
||||
*
|
||||
* `true` means Chromium never starts the navigation. A serving host of null means no generation is
|
||||
* applied, so there is no document to allow yet.
|
||||
*/
|
||||
internal fun mobileWebShellDropsNavigation(
|
||||
parts: MobileWebShellRequestParts,
|
||||
originHost: String?,
|
||||
isForMainFrame: Boolean
|
||||
): Boolean {
|
||||
if (!isForMainFrame || originHost == null) return true
|
||||
return resolveMobileWebShellRequestPath(parts, originHost) != "/"
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.ScriptHandler
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
|
||||
/**
|
||||
* CSP is the fence for fetch and XMLHttpRequest. This script exists only for the two things the
|
||||
* native layer is never shown: a WebSocket handshake, which neither `blockNetworkLoads` nor
|
||||
* `shouldInterceptRequest` sees, and a service worker registration, whose only native control is
|
||||
* process-global and would reconfigure the app's other WebViews. Kept in step with the iOS copy.
|
||||
* `configurable: false` with `writable: false` is the only property shape the page cannot put back.
|
||||
*/
|
||||
internal val MOBILE_WEB_SHELL_NETWORK_API_BLOCKER = """
|
||||
(function(){
|
||||
var deny=function(){throw new TypeError('Network access is disabled')};
|
||||
try{Object.defineProperty(globalThis,'WebSocket',{value:deny,configurable:false,writable:false})}catch(_){}
|
||||
try{Object.defineProperty(Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false})}catch(_){}
|
||||
try{Object.defineProperty(navigator,'serviceWorker',{value:undefined,configurable:false,writable:false})}catch(_){}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* Null when the WebView provider is older than the document-start script feature (Chromium 83).
|
||||
* The feature query is the capability; a version string is not, so nothing here parses one.
|
||||
*/
|
||||
internal fun installMobileWebShellNetworkApiBlocker(
|
||||
webView: WebView,
|
||||
allowedOrigin: String
|
||||
): ScriptHandler? {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
|
||||
return runCatching {
|
||||
WebViewCompat.addDocumentStartJavaScript(
|
||||
webView,
|
||||
MOBILE_WEB_SHELL_NETWORK_API_BLOCKER,
|
||||
setOf(allowedOrigin)
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal const val MOBILE_WEB_SHELL_SCHEME = "https"
|
||||
internal const val MOBILE_WEB_SHELL_MAX_URL_LENGTH = 8 * 1024
|
||||
private const val MOBILE_WEB_SHELL_ORIGIN_SUFFIX = ".orca-mobile-web.invalid"
|
||||
private const val MOBILE_WEB_SHELL_LABEL_LENGTH = 32
|
||||
private const val MOBILE_WEB_SHELL_MAX_SESSION_ID_LENGTH = 128
|
||||
|
||||
internal fun isMobileWebShellSessionId(sessionId: String): Boolean =
|
||||
sessionId.isNotEmpty() &&
|
||||
sessionId.length <= MOBILE_WEB_SHELL_MAX_SESSION_ID_LENGTH &&
|
||||
sessionId.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' }
|
||||
|
||||
/**
|
||||
* The host label is a slice of the session id's digest, never a slice of the session id.
|
||||
*
|
||||
* Session ids are base64url, and `https` is a special scheme, so Chromium ASCII-lowercases every
|
||||
* host it loads and reports back while `java.net.URI.getHost()` answers null for a label holding
|
||||
* `_`. The host the interceptor compared against then never equalled the one it was handed, and
|
||||
* every asset fell to the refusal branch as a 403. Lowercase hex is canonical under both parsers,
|
||||
* 32 characters because a DNS label caps at 63 octets, and `.invalid` is reserved by RFC 2606 so it
|
||||
* can never resolve.
|
||||
*/
|
||||
internal fun mobileWebShellOriginHost(sessionId: String): String? {
|
||||
if (!isMobileWebShellSessionId(sessionId)) return null
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray(Charsets.UTF_8))
|
||||
val label = digest.joinToString("") { byte -> "%02x".format(byte) }
|
||||
.take(MOBILE_WEB_SHELL_LABEL_LENGTH)
|
||||
return "$label$MOBILE_WEB_SHELL_ORIGIN_SUFFIX"
|
||||
}
|
||||
|
||||
internal fun mobileWebShellOrigin(sessionId: String): String? =
|
||||
mobileWebShellOriginHost(sessionId)?.let { host -> "$MOBILE_WEB_SHELL_SCHEME://$host" }
|
||||
|
||||
/** A request reduced to the components the predicate reads, so it needs no `android.net.Uri`. */
|
||||
internal data class MobileWebShellRequestParts(
|
||||
val method: String,
|
||||
val hasRangeHeader: Boolean,
|
||||
val scheme: String?,
|
||||
val host: String?,
|
||||
val port: Int,
|
||||
val userInfo: String?,
|
||||
val query: String?,
|
||||
val fragment: String?,
|
||||
val encodedPath: String?,
|
||||
val urlLength: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* The map key for a request we are willing to answer, or null to refuse. Every clause is an allow,
|
||||
* so a component nobody anticipated falls to refusal rather than through it.
|
||||
*/
|
||||
internal fun resolveMobileWebShellRequestPath(
|
||||
parts: MobileWebShellRequestParts,
|
||||
originHost: String
|
||||
): String? {
|
||||
val path = parts.encodedPath ?: return null
|
||||
if (parts.method != "GET" || parts.hasRangeHeader) return null
|
||||
if (parts.scheme != MOBILE_WEB_SHELL_SCHEME) return null
|
||||
// Hosts are case-insensitive, so a parser that canonicalised one must still bind to this session.
|
||||
if (parts.host == null || !parts.host.equals(originHost, ignoreCase = true)) return null
|
||||
if (parts.port != -1 || parts.userInfo != null) return null
|
||||
if (parts.query != null || parts.fragment != null) return null
|
||||
if (parts.urlLength > MOBILE_WEB_SHELL_MAX_URL_LENGTH || path.contains('%')) return null
|
||||
if (path.isEmpty() || path == "/") return "/"
|
||||
if (!path.startsWith("/")) return null
|
||||
return path
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/**
|
||||
* What a request outside the manifest map is answered with. A refusal is a response, never a null:
|
||||
* returning null from `shouldInterceptRequest` hands the request to Chromium's own loader, which is
|
||||
* the one path out of this origin that the settings cannot close.
|
||||
*
|
||||
* The body is empty on purpose. There is nothing to say to a page that asked for something it was
|
||||
* never given, and a body is one more thing an error page could render.
|
||||
*/
|
||||
internal const val MOBILE_WEB_SHELL_REFUSAL_STATUS = 403
|
||||
internal const val MOBILE_WEB_SHELL_REFUSAL_REASON = "Forbidden"
|
||||
internal const val MOBILE_WEB_SHELL_REFUSAL_MIME_TYPE = "text/plain"
|
||||
internal const val MOBILE_WEB_SHELL_REFUSAL_CHARSET = "utf-8"
|
||||
|
||||
internal val MOBILE_WEB_SHELL_REFUSAL_HEADERS = mapOf("Cache-Control" to "no-store")
|
||||
|
||||
internal fun mobileWebShellRefusalBody(): ByteArray = ByteArray(0)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
/**
|
||||
* The headers one served asset answers with. Content-Type is not among them: `WebResourceResponse`
|
||||
* takes the mime type and the encoding as separate arguments.
|
||||
*
|
||||
* The policy header rides the document and nothing else: on a script or a stylesheet response it is
|
||||
* inert, and sending it everywhere would hide which response is the one that has to carry it.
|
||||
*/
|
||||
internal fun mobileWebShellResponseHeaders(path: String, byteCount: Int): Map<String, String> {
|
||||
val headers = mutableMapOf(
|
||||
"Content-Length" to byteCount.toString(),
|
||||
"Cache-Control" to "no-store",
|
||||
"X-Content-Type-Options" to "nosniff"
|
||||
)
|
||||
if (path == "/") {
|
||||
headers["Content-Security-Policy"] = MOBILE_WEB_SHELL_CSP
|
||||
}
|
||||
return headers
|
||||
}
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Message
|
||||
import android.view.View
|
||||
import android.webkit.RenderProcessGoneDetail
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.ScriptHandler
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.exception.CodedException
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
/**
|
||||
* What the interceptor is currently allowed to answer. One immutable value, because the map and the
|
||||
* host it is keyed against are written on the main thread and read on Chromium's: two fields would
|
||||
* let a request see a new generation against the old host, and a plain field would let it see a
|
||||
* stale null and refuse a frame we had just served.
|
||||
*/
|
||||
private class MobileWebShellServed(
|
||||
val generation: MobileWebShellGeneration,
|
||||
val originHost: String
|
||||
)
|
||||
|
||||
@SuppressLint("ViewConstructor", "SetJavaScriptEnabled")
|
||||
internal class OrcaMobileWebShellView(
|
||||
context: Context,
|
||||
appContext: AppContext
|
||||
) : ExpoView(context, appContext) {
|
||||
private val onLoadState by EventDispatcher<Map<String, Any>>()
|
||||
private val onBridgeMessage by EventDispatcher<Map<String, Any>>()
|
||||
|
||||
private var generationDirectory = ""
|
||||
private var sessionId = ""
|
||||
private var bridgeEnabled = false
|
||||
private var bridgeInstalled = false
|
||||
private val bridgeGate = MobileWebShellBridgeGate()
|
||||
// Chromium hands a reply proxy to the listener, so native cannot speak first. The envelope has
|
||||
// the page send `ready` before anything is delivered, so there is nothing to speak first about.
|
||||
// Volatile for the same reason as `documentFailed`: `reportDocumentFailure` drops the proxy from
|
||||
// whichever thread `shouldInterceptRequest` ran on, and the listener reads it on the UI thread.
|
||||
@Volatile private var replyProxy: JavaScriptReplyProxy? = null
|
||||
private var applied: MobileWebShellAppliedProps? = null
|
||||
private val loadState = MobileWebShellLoadStateMachine()
|
||||
// Written on the main thread, read from onPageStarted/onPageFinished, which Chromium runs after
|
||||
// the failure that hid the view; `shouldInterceptRequest` also runs off the main thread.
|
||||
@Volatile private var documentFailed = false
|
||||
@Volatile private var served: MobileWebShellServed? = null
|
||||
private var blocker: ScriptHandler? = null
|
||||
private var webView: WebView? = createWebView()
|
||||
|
||||
init {
|
||||
addView(webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
fun setGenerationDirectory(value: String) {
|
||||
generationDirectory = value
|
||||
}
|
||||
|
||||
fun setSessionId(value: String) {
|
||||
sessionId = value
|
||||
}
|
||||
|
||||
fun setBridgeEnabled(value: Boolean) {
|
||||
bridgeEnabled = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Props arrive in no defined order, so neither setter starts anything; this does, once both are
|
||||
* in. A repeat of the same triple is not a retry: a retry is a remount under a new React key.
|
||||
*/
|
||||
fun propsDidUpdate() {
|
||||
val next = MobileWebShellAppliedProps(generationDirectory, sessionId, bridgeEnabled)
|
||||
if (applied?.matches(next) == true) return
|
||||
applied = next
|
||||
documentFailed = false
|
||||
loadState.reset()
|
||||
val view = webView
|
||||
if (view == null) {
|
||||
// onRenderProcessGone destroyed it. Recovery is a remount, so a new prop pair on the corpse
|
||||
// is still a failure, and one that says so beats one that goes quiet forever.
|
||||
emit(loadState.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE))
|
||||
return
|
||||
}
|
||||
view.stopLoading()
|
||||
emit(loadState.started())
|
||||
|
||||
val origin = mobileWebShellOrigin(sessionId)
|
||||
val host = mobileWebShellOriginHost(sessionId)
|
||||
if (origin == null || host == null) {
|
||||
// The private origin is the isolation primitive; a malformed session id leaves us without one.
|
||||
failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
val loaded = MobileWebShellGeneration.load(generationDirectory)
|
||||
if (loaded == null) {
|
||||
failPropUpdate(MobileWebShellFailureReason.GENERATION_UNREADABLE)
|
||||
return
|
||||
}
|
||||
blocker?.remove()
|
||||
blocker = installMobileWebShellNetworkApiBlocker(view, origin)
|
||||
if (blocker == null) {
|
||||
failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
if (!applyBridgeListener(view, origin)) {
|
||||
failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
served = MobileWebShellServed(loaded, host)
|
||||
view.visibility = View.VISIBLE
|
||||
view.loadUrl("$origin/")
|
||||
}
|
||||
|
||||
/**
|
||||
* The generation that failed to apply replaces whatever was on screen; leaving the previous one
|
||||
* served and visible would show a page the caller has just been told is not loaded.
|
||||
*/
|
||||
private fun failPropUpdate(reason: MobileWebShellFailureReason) {
|
||||
// The listener outlives the props it was installed under, and the document it was installed
|
||||
// for is still alive after `stopLoading`: left in place it would keep posting through an
|
||||
// origin this mount has just stopped serving, and re-arm the reply proxy doing it.
|
||||
removeBridgeListener()
|
||||
served = null
|
||||
webView?.visibility = View.INVISIBLE
|
||||
emit(loadState.failed(reason))
|
||||
}
|
||||
|
||||
/**
|
||||
* `addWebMessageListener` is the whole install: Chromium injects an `orcaBridge` object of the
|
||||
* agreed shape before any page script runs, and enforces the allowed origin itself, which is why
|
||||
* the listener needs no origin check of its own. Answers false only for a provider too old to
|
||||
* offer the listener at all.
|
||||
*/
|
||||
private fun applyBridgeListener(view: WebView, origin: String): Boolean {
|
||||
removeBridgeListener()
|
||||
val outcome = mobileWebShellBridgeInstall(
|
||||
bridgeEnabled,
|
||||
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
|
||||
)
|
||||
if (outcome != MobileWebShellBridgeInstall.INSTALL) {
|
||||
return outcome == MobileWebShellBridgeInstall.SKIP
|
||||
}
|
||||
return runCatching {
|
||||
WebViewCompat.addWebMessageListener(
|
||||
view,
|
||||
MOBILE_WEB_SHELL_BRIDGE_OBJECT,
|
||||
setOf(origin),
|
||||
bridgeListener
|
||||
)
|
||||
bridgeInstalled = true
|
||||
}.isSuccess
|
||||
}
|
||||
|
||||
/** The one way the bridge goes away, so no disable path can leave a listener behind. */
|
||||
private fun removeBridgeListener() {
|
||||
val view = webView
|
||||
if (bridgeInstalled && view != null) {
|
||||
WebViewCompat.removeWebMessageListener(view, MOBILE_WEB_SHELL_BRIDGE_OBJECT)
|
||||
}
|
||||
bridgeInstalled = false
|
||||
replyProxy = null
|
||||
}
|
||||
|
||||
/** Chromium calls this on the UI thread, which is also the only thread that may reply. */
|
||||
private val bridgeListener = WebViewCompat.WebMessageListener {
|
||||
_, message, _, isMainFrame, proxy ->
|
||||
val isStringMessage = message.type == WebMessageCompat.TYPE_STRING
|
||||
val json = if (isStringMessage) message.data else null
|
||||
if (
|
||||
acceptsMobileWebShellBridgeFrame(
|
||||
isMainFrame,
|
||||
isStringMessage,
|
||||
loadState.hasCommittedDocument
|
||||
) && json != null &&
|
||||
bridgeGate.accepts(json.toByteArray(Charsets.UTF_8).size)
|
||||
) {
|
||||
replyProxy = proxy
|
||||
onBridgeMessage(mapOf("json" to json))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown rather than dropped: the only caller is the React Native host, and a silent drop would
|
||||
* turn a chunking bug there into a request that never settles.
|
||||
*/
|
||||
fun postBridgeMessage(json: String) {
|
||||
val proxy = replyProxy ?: throw MobileWebShellBridgeUnavailableException()
|
||||
val byteCount = json.toByteArray(Charsets.UTF_8).size
|
||||
if (!acceptsMobileWebShellBridgeByteCount(byteCount)) {
|
||||
throw MobileWebShellBridgeMessageTooLargeException(byteCount)
|
||||
}
|
||||
proxy.postMessage(json)
|
||||
}
|
||||
|
||||
/** Expo calls this once React Native is done with the view, and onRenderProcessGone calls it. */
|
||||
fun destroyWebView() {
|
||||
val view = webView ?: return
|
||||
removeBridgeListener()
|
||||
loadState.documentEnded()
|
||||
webView = null
|
||||
blocker?.remove()
|
||||
blocker = null
|
||||
served = null
|
||||
documentFailed = false
|
||||
view.stopLoading()
|
||||
removeView(view)
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
// databaseEnabled and the two file-URL settings are deprecated and inert on new WebViews, but
|
||||
// the floor here is Chromium 83, and an invariant left to a default is one nobody can read.
|
||||
//
|
||||
// device-checked in B4: no setting below can be proven from a JVM test, and neither can
|
||||
// shouldOverrideUrlLoading dropping a navigation. Confirm on a device that a page cannot reach
|
||||
// the network (blockNetworkLoads), cannot keep state across a remount (domStorageEnabled,
|
||||
// databaseEnabled, cacheMode), cannot read a file or a content provider (allowFileAccess,
|
||||
// allowContentAccess, the two file-URL settings), cannot load http (mixedContentMode), and
|
||||
// cannot navigate away from the document.
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createWebView(): WebView {
|
||||
val view = WebView(context)
|
||||
view.setBackgroundColor(Color.TRANSPARENT)
|
||||
view.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = false
|
||||
databaseEnabled = false
|
||||
allowFileAccess = false
|
||||
allowFileAccessFromFileURLs = false
|
||||
allowUniversalAccessFromFileURLs = false
|
||||
allowContentAccess = false
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
blockNetworkLoads = true
|
||||
mediaPlaybackRequiresUserGesture = true
|
||||
setGeolocationEnabled(false)
|
||||
}
|
||||
// Never clearCache(true): that is process-global and would wipe the HTTP cache of every other
|
||||
// WebView in the app, including the terminal's. LOAD_NO_CACHE plus no-store is per view.
|
||||
view.webViewClient = ShellWebViewClient()
|
||||
view.webChromeClient = object : WebChromeClient() {
|
||||
override fun onCreateWindow(
|
||||
view: WebView?,
|
||||
isDialog: Boolean,
|
||||
isUserGesture: Boolean,
|
||||
resultMsg: Message?
|
||||
): Boolean = false
|
||||
}
|
||||
view.setDownloadListener { _, _, _, _, _ -> }
|
||||
return view
|
||||
}
|
||||
|
||||
private fun emit(emission: MobileWebShellLoadEmission?) {
|
||||
if (emission != null) onLoadState(emission.toPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium commits its own error document after `onReceivedError` returns, so hiding the WebView
|
||||
* synchronously is undone a moment later; posting is what keeps the shell's own state the only
|
||||
* thing on screen. `shouldInterceptRequest` also runs off the main thread.
|
||||
*/
|
||||
private fun reportDocumentFailure() {
|
||||
replyProxy = null
|
||||
// Synchronously, unlike the emission: the error document commits before the post runs, and a
|
||||
// page that failed is not one to hear from in the meantime.
|
||||
loadState.documentEnded()
|
||||
// Set before the post, not inside it: onPageFinished runs in between and would otherwise
|
||||
// report `ready` over the failure and make the error page visible again.
|
||||
documentFailed = true
|
||||
val epoch = loadState.epoch
|
||||
post {
|
||||
if (!documentFailed) return@post
|
||||
val emission = loadState.failedDuring(
|
||||
epoch,
|
||||
MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED
|
||||
) ?: return@post
|
||||
webView?.visibility = View.INVISIBLE
|
||||
emit(emission)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDocumentUrl(url: Uri): Boolean {
|
||||
val host = served?.originHost ?: return false
|
||||
return resolveMobileWebShellRequestPath(requestParts(url), host) == "/"
|
||||
}
|
||||
|
||||
private fun requestParts(
|
||||
url: Uri,
|
||||
method: String = "GET",
|
||||
hasRangeHeader: Boolean = false
|
||||
): MobileWebShellRequestParts = MobileWebShellRequestParts(
|
||||
method = method,
|
||||
hasRangeHeader = hasRangeHeader,
|
||||
scheme = url.scheme,
|
||||
host = url.host,
|
||||
port = url.port,
|
||||
userInfo = url.userInfo,
|
||||
query = url.query,
|
||||
fragment = url.fragment,
|
||||
encodedPath = url.encodedPath,
|
||||
urlLength = url.toString().length
|
||||
)
|
||||
|
||||
private fun serveRequest(request: WebResourceRequest): WebResourceResponse? {
|
||||
val current = served ?: return null
|
||||
val parts = requestParts(
|
||||
request.url,
|
||||
method = request.method,
|
||||
hasRangeHeader = request.requestHeaders.keys.any { it.equals("Range", ignoreCase = true) }
|
||||
)
|
||||
val path = resolveMobileWebShellRequestPath(parts, current.originHost) ?: return null
|
||||
val asset = current.generation.entries[path] ?: return null
|
||||
val bytes = runCatching { asset.file.readBytes() }.getOrNull() ?: return null
|
||||
val headers = mobileWebShellResponseHeaders(path, bytes.size)
|
||||
val (mimeType, charset) = splitMobileWebShellContentType(asset.contentType)
|
||||
return WebResourceResponse(mimeType, charset, 200, "OK", headers, ByteArrayInputStream(bytes))
|
||||
}
|
||||
|
||||
private fun refusedResponse(): WebResourceResponse = WebResourceResponse(
|
||||
MOBILE_WEB_SHELL_REFUSAL_MIME_TYPE,
|
||||
MOBILE_WEB_SHELL_REFUSAL_CHARSET,
|
||||
MOBILE_WEB_SHELL_REFUSAL_STATUS,
|
||||
MOBILE_WEB_SHELL_REFUSAL_REASON,
|
||||
MOBILE_WEB_SHELL_REFUSAL_HEADERS,
|
||||
ByteArrayInputStream(mobileWebShellRefusalBody())
|
||||
)
|
||||
|
||||
private inner class ShellWebViewClient : WebViewClient() {
|
||||
/** Never null, so no request can fall through to the network. */
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse {
|
||||
val response = serveRequest(request)
|
||||
if (response != null) return response
|
||||
if (request.isForMainFrame) reportDocumentFailure()
|
||||
return refusedResponse()
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
|
||||
mobileWebShellDropsNavigation(
|
||||
requestParts(request.url),
|
||||
served?.originHost,
|
||||
request.isForMainFrame
|
||||
)
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
// The document that spoke is being replaced, so its proxy stops being somewhere to post: the
|
||||
// next one has to say `ready` first, which is what the envelope has it do.
|
||||
replyProxy = null
|
||||
loadState.documentEnded()
|
||||
if (documentFailed || !isDocumentUrl(Uri.parse(url))) return
|
||||
// The load the caller was told about is the one now on screen, so this is where the page
|
||||
// becomes something to hear. Chromium runs page script after this.
|
||||
loadState.committed()
|
||||
emit(loadState.started())
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (documentFailed || !isDocumentUrl(Uri.parse(url))) return
|
||||
view.visibility = View.VISIBLE
|
||||
view.clearHistory()
|
||||
emit(loadState.finished())
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError
|
||||
) {
|
||||
if (request.isForMainFrame) reportDocumentFailure()
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
errorResponse: WebResourceResponse
|
||||
) {
|
||||
if (request.isForMainFrame) reportDocumentFailure()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returning false would kill the app. The dead WebView is destroyed and not rebuilt: renderer
|
||||
* memory pressure, a provider update and a bad bundle are indistinguishable here, so the retry
|
||||
* policy is the caller's and lives in one place.
|
||||
*/
|
||||
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||||
destroyWebView()
|
||||
emit(loadState.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE))
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class MobileWebShellBridgeUnavailableException :
|
||||
CodedException("The mobile web shell bridge is not installed on this view")
|
||||
|
||||
internal class MobileWebShellBridgeMessageTooLargeException(byteCount: Int) : CodedException(
|
||||
"A bridge message of $byteCount bytes exceeds the " +
|
||||
"$MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES byte cap"
|
||||
)
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class OrcaMobileWebShellModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("OrcaMobileWebShell")
|
||||
|
||||
View(OrcaMobileWebShellView::class) {
|
||||
Events("onLoadState", "onBridgeMessage")
|
||||
|
||||
Prop("generationDirectory") { view: OrcaMobileWebShellView, value: String ->
|
||||
view.setGenerationDirectory(value)
|
||||
}
|
||||
|
||||
Prop("sessionId") { view: OrcaMobileWebShellView, value: String ->
|
||||
view.setSessionId(value)
|
||||
}
|
||||
|
||||
Prop("bridgeEnabled") { view: OrcaMobileWebShellView, value: Boolean ->
|
||||
view.setBridgeEnabled(value)
|
||||
}
|
||||
|
||||
AsyncFunction("postBridgeMessage") { view: OrcaMobileWebShellView, json: String ->
|
||||
view.postBridgeMessage(json)
|
||||
}
|
||||
|
||||
OnViewDidUpdateProps { view: OrcaMobileWebShellView ->
|
||||
view.propsDidUpdate()
|
||||
}
|
||||
|
||||
OnViewDestroys { view: OrcaMobileWebShellView ->
|
||||
view.destroyWebView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MobileWebShellAppliedPropsTest {
|
||||
private fun props(
|
||||
generationDirectory: String = "/gen/aa",
|
||||
sessionId: String = "sess-01JN_aZ9",
|
||||
bridgeEnabled: Boolean = true
|
||||
) = MobileWebShellAppliedProps(generationDirectory, sessionId, bridgeEnabled)
|
||||
|
||||
@Test
|
||||
fun `the same triple does not re-enter`() {
|
||||
assertTrue(props().matches(props()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every field re-enters on its own`() {
|
||||
assertFalse(props().matches(props(generationDirectory = "/gen/ab")))
|
||||
assertFalse(props().matches(props(sessionId = "sess-01JN_aZ8")))
|
||||
assertFalse(props().matches(props(bridgeEnabled = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compares every stored field`() {
|
||||
// A fourth prop that nobody compared is a prop that silently never reloads, so the record's
|
||||
// shape is pinned here rather than left to whoever adds the field.
|
||||
val fields = MobileWebShellAppliedProps::class.java.declaredFields
|
||||
.filterNot { it.isSynthetic }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
assertEquals(listOf("bridgeEnabled", "generationDirectory", "sessionId"), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a triple that failed to apply is still applied`() {
|
||||
// The prop pair that could not install the listener is compared like any other: the caller sees
|
||||
// isolation-unavailable once, not on every commit for the life of the mount.
|
||||
val failed = props(generationDirectory = "/gen/corrupt")
|
||||
assertTrue(failed.matches(props(generationDirectory = "/gen/corrupt")))
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MobileWebShellBridgeTest {
|
||||
@Test
|
||||
fun `caps a message at 640 KiB of raw bytes`() {
|
||||
val cap = MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES
|
||||
assertEquals(640 * 1024, cap)
|
||||
assertTrue(acceptsMobileWebShellBridgeByteCount(0))
|
||||
assertTrue(acceptsMobileWebShellBridgeByteCount(cap - 1))
|
||||
assertTrue(acceptsMobileWebShellBridgeByteCount(cap))
|
||||
assertFalse(acceptsMobileWebShellBridgeByteCount(cap + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measures the cap in UTF-8 bytes, not characters`() {
|
||||
// A multi-byte payload must not buy extra room; the view measures the same way.
|
||||
val wide = "😀".repeat(4)
|
||||
assertEquals(8, wide.length)
|
||||
assertEquals(16, wide.toByteArray(Charsets.UTF_8).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counts every refusal and lets nothing under the cap through uncounted`() {
|
||||
val cap = MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES
|
||||
val gate = MobileWebShellBridgeGate()
|
||||
assertEquals(0, gate.refusedCount)
|
||||
assertTrue(gate.accepts(cap))
|
||||
assertEquals(0, gate.refusedCount)
|
||||
assertFalse(gate.accepts(cap + 1))
|
||||
assertFalse(gate.accepts(cap * 2))
|
||||
assertEquals(2, gate.refusedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hears only a string message from the main frame of a committed document`() {
|
||||
assertTrue(frame())
|
||||
// CSP says frame-src 'none', but Chromium injects the object into every same-origin frame, so
|
||||
// the shell states the rule itself rather than inheriting it from a header C0.7 has to relax.
|
||||
assertFalse(frame(isMainFrame = false))
|
||||
// An ArrayBuffer message: getData() throws on one, and base64 in JSON is the only binary lane.
|
||||
assertFalse(frame(isStringMessage = false))
|
||||
assertFalse(frame(isMainFrame = false, isStringMessage = false))
|
||||
// The document the current props replaced, still alive and still same-origin, speaking for a
|
||||
// load the caller has already been told is `loading`.
|
||||
assertFalse(frame(hasCommittedDocument = false))
|
||||
}
|
||||
|
||||
private fun frame(
|
||||
isMainFrame: Boolean = true,
|
||||
isStringMessage: Boolean = true,
|
||||
hasCommittedDocument: Boolean = true
|
||||
) = acceptsMobileWebShellBridgeFrame(isMainFrame, isStringMessage, hasCommittedDocument)
|
||||
|
||||
@Test
|
||||
fun `asks for the listener only when the bridge was asked for`() {
|
||||
// The floor is a feature query, never a version string. With the prop false the shell must
|
||||
// still load on a provider that could not have run the bridge at all.
|
||||
assertEquals(
|
||||
MobileWebShellBridgeInstall.SKIP,
|
||||
mobileWebShellBridgeInstall(bridgeEnabled = false, isListenerSupported = false)
|
||||
)
|
||||
assertEquals(
|
||||
MobileWebShellBridgeInstall.SKIP,
|
||||
mobileWebShellBridgeInstall(bridgeEnabled = false, isListenerSupported = true)
|
||||
)
|
||||
assertEquals(
|
||||
MobileWebShellBridgeInstall.INSTALL,
|
||||
mobileWebShellBridgeInstall(bridgeEnabled = true, isListenerSupported = true)
|
||||
)
|
||||
assertEquals(
|
||||
MobileWebShellBridgeInstall.UNAVAILABLE,
|
||||
mobileWebShellBridgeInstall(bridgeEnabled = true, isListenerSupported = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `names the injected object the same thing on both platforms`() {
|
||||
// iOS installs a global of this name from its document-start script; a swap here is a page that
|
||||
// reaches one shell and not the other.
|
||||
assertEquals("orcaBridge", MOBILE_WEB_SHELL_BRIDGE_OBJECT)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MobileWebShellCspTest {
|
||||
@Test
|
||||
fun `states every fetching directive so nothing falls back to the default`() {
|
||||
val directives = MOBILE_WEB_SHELL_CSP.split("; ")
|
||||
assertTrue(directives.contains("default-src 'none'"))
|
||||
assertTrue(directives.contains("script-src 'self'"))
|
||||
// React Native Web injects runtime styles with no nonce; see MobileWebShellCsp.
|
||||
assertTrue(directives.contains("style-src 'self' 'unsafe-inline'"))
|
||||
assertTrue(directives.contains("img-src 'self'"))
|
||||
// The bootstrap page reads ./manifest.json from its own origin, which is one read-only
|
||||
// directory behind the manifest map, so 'self' reaches nothing it cannot already read.
|
||||
assertTrue(directives.contains("connect-src 'self'"))
|
||||
assertTrue(directives.contains("worker-src 'none'"))
|
||||
assertTrue(directives.contains("frame-src 'none'"))
|
||||
assertTrue(directives.contains("child-src 'none'"))
|
||||
assertTrue(directives.contains("object-src 'none'"))
|
||||
assertTrue(directives.contains("base-uri 'none'"))
|
||||
assertTrue(directives.contains("form-action 'none'"))
|
||||
assertTrue(directives.contains("frame-ancestors 'none'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grants nothing the build rules say the bundle never needs`() {
|
||||
// 'unsafe-inline' is granted to style-src and to nothing else: the page's code still has to
|
||||
// arrive as a fetched same-origin script, which is the directive that matters.
|
||||
val directives = MOBILE_WEB_SHELL_CSP.split("; ")
|
||||
assertEquals(
|
||||
listOf("style-src 'self' 'unsafe-inline'"),
|
||||
directives.filter { it.contains("unsafe-inline") }
|
||||
)
|
||||
assertTrue(directives.contains("script-src 'self'"))
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-eval"))
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("data:"))
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("blob:"))
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("http"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `is a single header line`() {
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("\r"))
|
||||
assertFalse(MOBILE_WEB_SHELL_CSP.contains("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denies only what the native layer cannot see, with a shape the page cannot restore`() {
|
||||
val blocker = MOBILE_WEB_SHELL_NETWORK_API_BLOCKER
|
||||
// Whole definitions, not `contains("writable:false")`: one property's descriptor could lose a
|
||||
// flag and still match because another property still carries it.
|
||||
assertTrue(
|
||||
blocker.contains("globalThis,'WebSocket',{value:deny,configurable:false,writable:false}")
|
||||
)
|
||||
assertTrue(
|
||||
blocker.contains(
|
||||
"Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false}"
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
blocker.contains("navigator,'serviceWorker',{value:undefined,configurable:false,writable:false}")
|
||||
)
|
||||
// CSP is the fence for fetch and XMLHttpRequest; a script that replaced them would put one
|
||||
// policy in two places and hide which one is actually holding.
|
||||
assertFalse(blocker.contains("fetch"))
|
||||
assertFalse(blocker.contains("XMLHttpRequest"))
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private val DIRECTORY = File("/tmp/generation")
|
||||
|
||||
private fun asset(path: Any, contentType: Any): JSONObject =
|
||||
JSONObject().put("path", path).put("contentType", contentType)
|
||||
|
||||
private fun manifest(
|
||||
schemaVersion: Any = 1,
|
||||
entrypoint: Any = "index.html",
|
||||
assets: List<JSONObject> = listOf(
|
||||
asset("index.html", "text/html; charset=utf-8"),
|
||||
asset("assets/aa.js", "text/javascript; charset=utf-8"),
|
||||
asset("assets/bb.png", "image/png")
|
||||
)
|
||||
): String = JSONObject()
|
||||
.put("schemaVersion", schemaVersion)
|
||||
.put("entrypoint", entrypoint)
|
||||
.put("assets", JSONArray(assets))
|
||||
.toString()
|
||||
|
||||
private fun make(json: String) = MobileWebShellGeneration.make(json, DIRECTORY)
|
||||
|
||||
class MobileWebShellGenerationTest {
|
||||
@Test
|
||||
fun `maps the document, every declared asset and the manifest itself`() {
|
||||
val generation = make(manifest())
|
||||
assertNotNull(generation)
|
||||
val entries = generation!!.entries
|
||||
assertEquals(4, entries.size)
|
||||
assertEquals(File(DIRECTORY, "index.html"), entries["/"]!!.file)
|
||||
assertEquals("text/html; charset=utf-8", entries["/"]!!.contentType)
|
||||
// Only "/" reaches the document: a second URL for the same bytes would answer without the CSP
|
||||
// header, which rides the document response alone.
|
||||
assertNull(entries["/index.html"])
|
||||
assertEquals(File(DIRECTORY, "assets/bb.png"), entries["/assets/bb.png"]!!.file)
|
||||
assertEquals("image/png", entries["/assets/bb.png"]!!.contentType)
|
||||
// The manifest is written last and is not part of the content hash, so it is not in assets[].
|
||||
assertEquals("application/json", entries["/manifest.json"]!!.contentType)
|
||||
assertNull(entries["/assets/cc.js"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses a manifest whose shape it does not recognise`() {
|
||||
assertNull(make("not json"))
|
||||
assertNull(make("[]"))
|
||||
assertNull(make(manifest(schemaVersion = 2)))
|
||||
assertNull(make(manifest(schemaVersion = "1")))
|
||||
assertNull(make(manifest(entrypoint = "start.html")))
|
||||
assertNull(make(manifest(assets = emptyList())))
|
||||
// Without the entrypoint among the assets, "/" would map to a file nobody declared.
|
||||
assertNull(make(manifest(assets = listOf(asset("assets/aa.js", "text/javascript")))))
|
||||
assertNull(make(manifest(assets = (0..256).map { asset("assets/a$it.js", "text/javascript") })))
|
||||
assertNotNull(make(manifest(assets = listOf(asset("index.html", "text/html")) +
|
||||
(0..254).map { asset("assets/a$it.js", "text/javascript") })))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses a manifest that declares a path or a content type it will not serve`() {
|
||||
assertNull(make(manifest(assets = listOf(
|
||||
asset("index.html", "text/html"),
|
||||
asset("../escape.js", "text/javascript")
|
||||
))))
|
||||
assertNull(make(manifest(assets = listOf(
|
||||
asset("index.html", "text/html"),
|
||||
asset("assets/aa.js", "text/javascript\r\nX-Injected: 1")
|
||||
))))
|
||||
assertNull(make(manifest(assets = listOf(
|
||||
asset("index.html", "text/html"),
|
||||
asset(7, "text/javascript")
|
||||
))))
|
||||
assertNull(make(manifest(assets = listOf(
|
||||
asset("index.html", "text/html"),
|
||||
asset("assets/aa.js", 7)
|
||||
))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts only portable relative asset paths`() {
|
||||
assertTrue(MobileWebShellGeneration.isServableAssetPath("index.html"))
|
||||
assertTrue(MobileWebShellGeneration.isServableAssetPath("assets/a-b_c.2.js"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath(""))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("/leading"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("trailing/"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("a//b"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("../secret"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("assets/../../secret"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("assets/./a.js"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("back\\slash"))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("has space.js"))
|
||||
assertTrue(MobileWebShellGeneration.isServableAssetPath("a".repeat(255)))
|
||||
assertFalse(MobileWebShellGeneration.isServableAssetPath("a".repeat(256)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts only a content type that cannot carry a second header`() {
|
||||
assertTrue(MobileWebShellGeneration.isServableContentType("image/png"))
|
||||
assertTrue(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8"))
|
||||
assertTrue(MobileWebShellGeneration.isServableContentType("application/manifest+json"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType(""))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("text/html\r\nX-Injected: 1"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8; x=1"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("TEXT/HTML"))
|
||||
// A header value we did not mint character for character is a value we did not check.
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("text/html; charset=UTF-8"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("text"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("text/html/extra"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("/html"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("-text/html"))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("text/html; charset="))
|
||||
assertFalse(MobileWebShellGeneration.isServableContentType("a".repeat(130) + "/b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splits the content type the way WebResourceResponse wants it`() {
|
||||
assertEquals("text/html" to "utf-8", splitMobileWebShellContentType("text/html; charset=utf-8"))
|
||||
assertEquals("image/png" to null, splitMobileWebShellContentType("image/png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses a directory path that is not absolute`() {
|
||||
assertNull(MobileWebShellGeneration.load("relative/generation"))
|
||||
assertNull(MobileWebShellGeneration.load(""))
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private fun failure(reason: String) = MobileWebShellLoadEmission("failed", reason)
|
||||
|
||||
class MobileWebShellLoadStateTest {
|
||||
@Test
|
||||
fun `spells each reason the way the TypeScript parser reads it`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"generation-unreadable",
|
||||
"isolation-unavailable",
|
||||
"document-load-failed",
|
||||
"render-process-gone"
|
||||
),
|
||||
MobileWebShellFailureReason.entries.map { it.wireName }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hears a document only between its commit and the end of that load`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
assertFalse(machine.hasCommittedDocument)
|
||||
machine.started()
|
||||
// The previous document is alive and same-origin until the next one commits.
|
||||
assertFalse(machine.hasCommittedDocument)
|
||||
machine.committed()
|
||||
assertTrue(machine.hasCommittedDocument)
|
||||
|
||||
// A new prop triple: the committed document is the one being replaced.
|
||||
machine.reset()
|
||||
assertFalse(machine.hasCommittedDocument)
|
||||
machine.committed()
|
||||
machine.documentEnded()
|
||||
assertFalse(machine.hasCommittedDocument)
|
||||
|
||||
// A failure ends the document, and nothing after it re-arms: a retry is a remount.
|
||||
machine.committed()
|
||||
machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE)
|
||||
assertFalse(machine.hasCommittedDocument)
|
||||
machine.committed()
|
||||
assertFalse(machine.hasCommittedDocument)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports a load in progress and then a load that finished`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
assertEquals(MobileWebShellLoadEmission("loading", null), machine.started())
|
||||
assertEquals(MobileWebShellLoadEmission("ready", null), machine.finished())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `says nothing twice in a row`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
assertNotNull(machine.started())
|
||||
assertNull(machine.started())
|
||||
assertNotNull(machine.finished())
|
||||
assertNull(machine.finished())
|
||||
}
|
||||
|
||||
// Chromium commits its error document after onReceivedError returns, so onPageFinished arrives
|
||||
// after the failure; reporting `ready` there would also un-hide the error page.
|
||||
@Test
|
||||
fun `a load that finished after a failure reports nothing`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
machine.started()
|
||||
assertEquals(
|
||||
failure("document-load-failed"),
|
||||
machine.failed(MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED)
|
||||
)
|
||||
assertNull(machine.finished())
|
||||
assertNull(machine.started())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second failure reports nothing, whatever its reason`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
assertEquals(
|
||||
failure("generation-unreadable"),
|
||||
machine.failed(MobileWebShellFailureReason.GENERATION_UNREADABLE)
|
||||
)
|
||||
assertNull(machine.failed(MobileWebShellFailureReason.GENERATION_UNREADABLE))
|
||||
assertNull(machine.failed(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE))
|
||||
assertNull(machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE))
|
||||
}
|
||||
|
||||
// Android defers a document failure past Chromium's error document, so a prop update can land
|
||||
// between the decision and the report; the failure belongs to the load that is already gone.
|
||||
@Test
|
||||
fun `a failure decided before a new prop pair reports nothing`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
machine.started()
|
||||
val epoch = machine.epoch
|
||||
machine.reset()
|
||||
assertNull(machine.failedDuring(epoch, MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED))
|
||||
assertEquals(MobileWebShellLoadEmission("ready", null), machine.finished())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure decided during the current load still reports`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
machine.started()
|
||||
assertEquals(
|
||||
failure("document-load-failed"),
|
||||
machine.failedDuring(machine.epoch, MobileWebShellFailureReason.DOCUMENT_LOAD_FAILED)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new prop pair may report again, including the same failure`() {
|
||||
val machine = MobileWebShellLoadStateMachine()
|
||||
machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE)
|
||||
machine.reset()
|
||||
assertEquals(
|
||||
failure("render-process-gone"),
|
||||
machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE)
|
||||
)
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private const val SESSION = "sess-01JN_aZ9"
|
||||
|
||||
private fun parts(
|
||||
path: String?,
|
||||
method: String = "GET",
|
||||
hasRangeHeader: Boolean = false,
|
||||
scheme: String? = "https",
|
||||
host: String? = mobileWebShellOriginHost(SESSION),
|
||||
port: Int = -1,
|
||||
userInfo: String? = null,
|
||||
query: String? = null,
|
||||
fragment: String? = null,
|
||||
urlLength: Int = 64
|
||||
) = MobileWebShellRequestParts(
|
||||
method = method,
|
||||
hasRangeHeader = hasRangeHeader,
|
||||
scheme = scheme,
|
||||
host = host,
|
||||
port = port,
|
||||
userInfo = userInfo,
|
||||
query = query,
|
||||
fragment = fragment,
|
||||
encodedPath = path,
|
||||
urlLength = urlLength
|
||||
)
|
||||
|
||||
private fun resolve(request: MobileWebShellRequestParts): String? =
|
||||
resolveMobileWebShellRequestPath(request, mobileWebShellOriginHost(SESSION)!!)
|
||||
|
||||
class MobileWebShellOriginTest {
|
||||
@Test
|
||||
fun `accepts only base64url session ids within the length bound`() {
|
||||
assertTrue(isMobileWebShellSessionId("aZ0-_"))
|
||||
assertTrue(isMobileWebShellSessionId("a".repeat(128)))
|
||||
assertFalse(isMobileWebShellSessionId("a".repeat(129)))
|
||||
assertFalse(isMobileWebShellSessionId(""))
|
||||
assertFalse(isMobileWebShellSessionId("has space"))
|
||||
assertFalse(isMobileWebShellSessionId("dots.are.hosts.too"))
|
||||
assertFalse(isMobileWebShellSessionId("sl/ash"))
|
||||
assertFalse(isMobileWebShellSessionId("sessioñ"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels the origin with a hash of the session id, never a slice of it`() {
|
||||
val host = mobileWebShellOriginHost(SESSION)!!
|
||||
val label = host.substringBefore('.')
|
||||
assertEquals(32, label.length)
|
||||
assertTrue(label.all { it in '0'..'9' || it in 'a'..'f' })
|
||||
// The bug this replaces: a label sliced off the session id carried case and '_', which
|
||||
// Chromium and java.net.URI canonicalise differently, so every asset 403'd.
|
||||
assertFalse(label.startsWith(SESSION.take(8)))
|
||||
assertEquals("$label.orca-mobile-web.invalid", host)
|
||||
assertEquals("https://$host", mobileWebShellOrigin(SESSION))
|
||||
assertNull(mobileWebShellOriginHost("bad host"))
|
||||
assertNull(mobileWebShellOrigin("bad host"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `derives a different label for every session and the same one for a repeat`() {
|
||||
assertEquals(mobileWebShellOriginHost(SESSION), mobileWebShellOriginHost(SESSION))
|
||||
assertTrue(mobileWebShellOriginHost(SESSION) != mobileWebShellOriginHost("${SESSION}a"))
|
||||
// Case matters to the derivation even though the host comparison ignores it.
|
||||
assertTrue(mobileWebShellOriginHost(SESSION) != mobileWebShellOriginHost(SESSION.uppercase()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serves the document and a declared asset path`() {
|
||||
assertEquals("/", resolve(parts("/")))
|
||||
assertEquals("/", resolve(parts("")))
|
||||
assertEquals("/assets/aa.js", resolve(parts("/assets/aa.js")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `binds a host the parser canonicalised`() {
|
||||
assertEquals("/", resolve(parts("/", host = mobileWebShellOriginHost(SESSION)!!.uppercase())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses everything outside a plain GET on this origin`() {
|
||||
assertNull(resolve(parts("/", method = "POST")))
|
||||
assertNull(resolve(parts("/", method = "HEAD")))
|
||||
assertNull(resolve(parts("/", hasRangeHeader = true)))
|
||||
assertNull(resolve(parts("/", scheme = "http")))
|
||||
assertNull(resolve(parts("/", scheme = null)))
|
||||
assertNull(resolve(parts("/", host = "other.orca-mobile-web.invalid")))
|
||||
assertNull(resolve(parts("/", host = null)))
|
||||
assertNull(resolve(parts("/", port = 443)))
|
||||
assertNull(resolve(parts("/", userInfo = "someone")))
|
||||
assertNull(resolve(parts("/", query = "v=1")))
|
||||
assertNull(resolve(parts("/", fragment = "frag")))
|
||||
assertNull(resolve(parts("/assets/%2e%2e/etc")))
|
||||
assertNull(resolve(parts("assets/aa.js")))
|
||||
assertNull(resolve(parts(null)))
|
||||
assertEquals("/", resolve(parts("/", urlLength = 8 * 1024)))
|
||||
assertNull(resolve(parts("/", urlLength = 8 * 1024 + 1)))
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private const val POLICY_SESSION = "sess-01JN_aZ9"
|
||||
private val POLICY_HOST = mobileWebShellOriginHost(POLICY_SESSION)!!
|
||||
|
||||
private fun navigation(
|
||||
path: String?,
|
||||
host: String? = POLICY_HOST,
|
||||
scheme: String? = "https",
|
||||
query: String? = null
|
||||
) = MobileWebShellRequestParts(
|
||||
method = "GET",
|
||||
hasRangeHeader = false,
|
||||
scheme = scheme,
|
||||
host = host,
|
||||
port = -1,
|
||||
userInfo = null,
|
||||
query = query,
|
||||
fragment = null,
|
||||
encodedPath = path,
|
||||
urlLength = 64
|
||||
)
|
||||
|
||||
class MobileWebShellRequestPolicyTest {
|
||||
@Test
|
||||
fun `lets the document of the served generation load`() {
|
||||
assertFalse(mobileWebShellDropsNavigation(navigation("/"), POLICY_HOST, true))
|
||||
assertFalse(mobileWebShellDropsNavigation(navigation(""), POLICY_HOST, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops everything else, so nothing the page builds can navigate`() {
|
||||
// A subresource path is servable but is not a document; a link out is neither.
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/assets/aa.js"), POLICY_HOST, true))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/", query = "v=1"), POLICY_HOST, true))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/", host = "example.com"), POLICY_HOST, true))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/", scheme = "http"), POLICY_HOST, true))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/", scheme = "file"), POLICY_HOST, true))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/", scheme = "intent"), POLICY_HOST, true))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation(null), POLICY_HOST, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops a subframe navigation and any navigation before a generation is served`() {
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/"), POLICY_HOST, false))
|
||||
assertTrue(mobileWebShellDropsNavigation(navigation("/"), null, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses with an empty forbidden response`() {
|
||||
assertEquals(403, MOBILE_WEB_SHELL_REFUSAL_STATUS)
|
||||
assertEquals(0, mobileWebShellRefusalBody().size)
|
||||
assertEquals("no-store", MOBILE_WEB_SHELL_REFUSAL_HEADERS["Cache-Control"])
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package expo.modules.orcamobilewebshell
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MobileWebShellResponseHeadersTest {
|
||||
@Test
|
||||
fun `sends the policy on the document`() {
|
||||
val headers = mobileWebShellResponseHeaders("/", 12)
|
||||
assertEquals(MOBILE_WEB_SHELL_CSP, headers["Content-Security-Policy"])
|
||||
assertEquals("12", headers["Content-Length"])
|
||||
assertEquals("no-store", headers["Cache-Control"])
|
||||
assertEquals("nosniff", headers["X-Content-Type-Options"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sends the policy on nothing else`() {
|
||||
for (path in listOf("/index.html", "/assets/aa.js", "/manifest.json", "/assets/bb.png")) {
|
||||
assertNull(mobileWebShellResponseHeaders(path, 12)["Content-Security-Policy"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `caches nothing, whatever the path`() {
|
||||
val headers = mobileWebShellResponseHeaders("/assets/aa.js", 0)
|
||||
assertEquals("no-store", headers["Cache-Control"])
|
||||
assertEquals("nosniff", headers["X-Content-Type-Options"])
|
||||
// WebResourceResponse takes the mime type and the encoding as arguments, not as a header.
|
||||
assertNull(headers["Content-Type"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["OrcaMobileWebShellModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.orcamobilewebshell.OrcaMobileWebShellModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
/// The prop triple a load was started for, and the only thing that decides whether the next prop
|
||||
/// commit re-enters.
|
||||
///
|
||||
/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc`
|
||||
/// and checks it without a device or a simulator.
|
||||
///
|
||||
/// Recording the props rather than the outcome is what makes a failure converge. A guard that reads
|
||||
/// whether the bridge actually installed never agrees with a prop that is true but could not be
|
||||
/// honoured — a malformed session id, an unreadable generation, a WebView too old for the listener
|
||||
/// — so every later commit re-enters, resets the machine, and re-emits loading then failed forever.
|
||||
struct MobileWebShellAppliedProps {
|
||||
var generationDirectory: String
|
||||
var sessionId: String
|
||||
var bridgeEnabled: Bool
|
||||
|
||||
/// Field by field rather than `Equatable`: a synthesized `==` would grow with any field added to
|
||||
/// the record, which is how a prop nobody meant to be a reload becomes one.
|
||||
func matches(_ other: MobileWebShellAppliedProps) -> Bool {
|
||||
generationDirectory == other.generationDirectory
|
||||
&& sessionId == other.sessionId
|
||||
&& bridgeEnabled == other.bridgeEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// The page ↔ native message channel: what it is called, how big a message may be, and the
|
||||
/// predicate that decides whether a script message came from the document we served.
|
||||
///
|
||||
/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc`
|
||||
/// and checks it without a device or a simulator.
|
||||
enum MobileWebShellBridge {
|
||||
/// The `WKScriptMessageHandler` name and the global the document-start script installs. Android
|
||||
/// uses the same name for its `WebMessageListener`, so one page reaches both shells.
|
||||
static let handlerName = "orcaBridge"
|
||||
|
||||
/// Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract
|
||||
/// holds the same ceiling; native is the one that cannot be talked out of it.
|
||||
static let maxMessageByteCount = 640 * 1024
|
||||
|
||||
/// Every clause is an allow, so a message shape nobody anticipated is refused rather than passed.
|
||||
///
|
||||
/// Simulator-verified 2026-09-18: `WKFrameInfo.securityOrigin` does populate for a custom scheme,
|
||||
/// but WebKit ASCII-lowercases the host, so `orca-mobile-web://sess-01JN_aZ9/` reports host
|
||||
/// `sess-01jn_az9`. Session ids are base64url and mixed case, so exact equality would refuse every
|
||||
/// message; the fold is `MobileWebShellOrigin.asciiLowercased`, shared with the request predicate.
|
||||
static func accepts(_ source: MobileWebShellBridgeSource, sessionId: String) -> Bool {
|
||||
guard
|
||||
source.isOurWebView,
|
||||
source.isMainFrame,
|
||||
source.hasCommittedDocument,
|
||||
source.originProtocol == MobileWebShellOrigin.scheme,
|
||||
MobileWebShellOrigin.isValidSessionId(sessionId),
|
||||
MobileWebShellOrigin.asciiLowercased(source.originHost)
|
||||
== MobileWebShellOrigin.asciiLowercased(sessionId)
|
||||
else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// WebKit hands the handler no reply proxy, so a native → page post has to name a frame itself.
|
||||
/// The frame is the one the last accepted message came from, and nil is the whole answer for a
|
||||
/// page that has never spoken, a load that failed and a renderer that died: a post with nowhere
|
||||
/// proven to go is refused, never delivered to whatever frame happens to be current.
|
||||
///
|
||||
/// `hasCommittedDocument` is the same arming acceptance reads. Between a new provisional
|
||||
/// navigation and its commit there is no document the held frame belongs to, and `WKFrameInfo` is
|
||||
/// a snapshot that outlives the frame it describes, so it cannot be asked.
|
||||
static func canPost(
|
||||
toFrameOriginHost host: String?,
|
||||
sessionId: String,
|
||||
hasCommittedDocument: Bool
|
||||
) -> Bool {
|
||||
guard
|
||||
hasCommittedDocument,
|
||||
let host,
|
||||
MobileWebShellOrigin.isValidSessionId(sessionId),
|
||||
MobileWebShellOrigin.asciiLowercased(host)
|
||||
== MobileWebShellOrigin.asciiLowercased(sessionId)
|
||||
else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
static func acceptsByteCount(_ byteCount: Int) -> Bool {
|
||||
byteCount <= maxMessageByteCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a native post may go: the frame of the last accepted message and the host that frame
|
||||
/// reported when it spoke. One value, so the frame and the host it is checked against can never be
|
||||
/// from different documents, and generic over the frame so the rule needs no WebKit type.
|
||||
///
|
||||
/// Held for the document that armed it and no longer. Every boundary that ends that document clears
|
||||
/// it — a new provisional navigation, the commit that replaces it, a load failure, a dead renderer,
|
||||
/// a prop update — so the document now on screen has to speak before anything is posted to it.
|
||||
struct MobileWebShellBridgeTarget<Frame> {
|
||||
private var armed: (frame: Frame, originHost: String)?
|
||||
|
||||
var frame: Frame? { armed?.frame }
|
||||
var originHost: String? { armed?.originHost }
|
||||
|
||||
mutating func arm(frame: Frame, originHost: String) {
|
||||
armed = (frame: frame, originHost: originHost)
|
||||
}
|
||||
|
||||
mutating func clear() {
|
||||
armed = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A script message reduced to what the predicate reads, so the predicate needs no WebKit type.
|
||||
struct MobileWebShellBridgeSource {
|
||||
var isOurWebView: Bool
|
||||
var isMainFrame: Bool
|
||||
/// Whether a document has committed under the props this message is being judged against.
|
||||
var hasCommittedDocument: Bool
|
||||
var originProtocol: String
|
||||
var originHost: String
|
||||
}
|
||||
|
||||
/// Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that
|
||||
/// learns which messages were dropped learns the cap. The tally is what a test can hold the cap to.
|
||||
final class MobileWebShellBridgeGate {
|
||||
private(set) var refusedCount = 0
|
||||
|
||||
func accepts(byteCount: Int) -> Bool {
|
||||
guard MobileWebShellBridge.acceptsByteCount(byteCount) else {
|
||||
refusedCount += 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
enum MobileWebShellCsp {
|
||||
/// Sent as a response header on the document and nowhere else: a served document must never
|
||||
/// carry its own policy, so there is no meta tag to find and no bundle change that can relax it.
|
||||
static let header = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
// React Native Web 0.21.2 injects its stylesheet at runtime with no nonce support, so the
|
||||
// Phase C page cannot paint under 'self' alone (measured: the render check under this exact
|
||||
// header). This relaxes styling only; script-src 'self' is untouched.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self'",
|
||||
"font-src 'none'",
|
||||
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
|
||||
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is
|
||||
// the fence for fetch and XMLHttpRequest; the document-start script covers only the two things
|
||||
// the native layer cannot see.
|
||||
"connect-src 'self'",
|
||||
"media-src 'none'",
|
||||
"object-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"worker-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
].joined(separator: "; ")
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
|
||||
struct MobileWebShellAsset {
|
||||
let file: URL
|
||||
let contentType: String
|
||||
}
|
||||
|
||||
enum MobileWebShellGenerationError: Error {
|
||||
case unreadable
|
||||
}
|
||||
|
||||
/// The served surface of one activated generation: a request path to file map, built once from the
|
||||
/// manifest before anything loads. Serving is a lookup in this map and never a path join at request
|
||||
/// time, so "not in the manifest" is a refusal by construction rather than by sanitiser.
|
||||
///
|
||||
/// Asset bytes are not re-hashed here. The TypeScript store verified every byte against the
|
||||
/// manifest before the activating rename, and the directory path is one the app owns and the page
|
||||
/// can never influence. Framework-free so `swiftc` can check it.
|
||||
struct MobileWebShellGeneration {
|
||||
static let manifestName = "manifest.json"
|
||||
static let manifestContentType = "application/json"
|
||||
static let schemaVersion = 1
|
||||
static let entrypoint = "index.html"
|
||||
static let maxAssets = 256
|
||||
static let maxAssetPathLength = 255
|
||||
static let maxContentTypeLength = 128
|
||||
|
||||
let entries: [String: MobileWebShellAsset]
|
||||
|
||||
static func load(directoryPath: String) throws -> MobileWebShellGeneration {
|
||||
guard directoryPath.hasPrefix("/") else { throw MobileWebShellGenerationError.unreadable }
|
||||
let directory = URL(fileURLWithPath: directoryPath, isDirectory: true)
|
||||
guard
|
||||
let data = try? Data(contentsOf: directory.appendingPathComponent(manifestName))
|
||||
else { throw MobileWebShellGenerationError.unreadable }
|
||||
return try make(manifestData: data, directory: directory)
|
||||
}
|
||||
|
||||
static func make(manifestData: Data, directory: URL) throws -> MobileWebShellGeneration {
|
||||
let parsed = try? JSONSerialization.jsonObject(with: manifestData)
|
||||
guard
|
||||
let root = parsed as? [String: Any],
|
||||
isPinnedSchemaVersion(root["schemaVersion"]),
|
||||
let declaredEntrypoint = root["entrypoint"] as? String,
|
||||
declaredEntrypoint == entrypoint,
|
||||
let assets = root["assets"] as? [[String: Any]],
|
||||
!assets.isEmpty,
|
||||
assets.count <= maxAssets
|
||||
else { throw MobileWebShellGenerationError.unreadable }
|
||||
|
||||
var entries: [String: MobileWebShellAsset] = [:]
|
||||
for asset in assets {
|
||||
guard
|
||||
let path = asset["path"] as? String,
|
||||
isServableAssetPath(path),
|
||||
let contentType = asset["contentType"] as? String,
|
||||
isServableContentType(contentType)
|
||||
else { throw MobileWebShellGenerationError.unreadable }
|
||||
entries["/\(path)"] = MobileWebShellAsset(
|
||||
file: directory.appendingPathComponent(path, isDirectory: false),
|
||||
contentType: contentType
|
||||
)
|
||||
}
|
||||
// Removed, not copied: the document answers at "/" and nowhere else, so the one response that
|
||||
// carries the policy header is the only way to reach those bytes.
|
||||
guard let document = entries.removeValue(forKey: "/\(entrypoint)") else {
|
||||
throw MobileWebShellGenerationError.unreadable
|
||||
}
|
||||
entries["/"] = document
|
||||
// The manifest is written last and is not part of the content hash, so it is not in `assets`;
|
||||
// the bootstrap page still reads it from its own origin.
|
||||
entries["/\(manifestName)"] = MobileWebShellAsset(
|
||||
file: directory.appendingPathComponent(manifestName, isDirectory: false),
|
||||
contentType: manifestContentType
|
||||
)
|
||||
return MobileWebShellGeneration(entries: entries)
|
||||
}
|
||||
|
||||
/// `as? Int` is not this check: NSNumber bridges `true` and `1.0` to 1, and the contract pins the
|
||||
/// integer 1. JSONSerialization keeps the written form, so the number's own type answers it.
|
||||
static func isPinnedSchemaVersion(_ value: Any?) -> Bool {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else {
|
||||
return false
|
||||
}
|
||||
let numberType = String(cString: number.objCType)
|
||||
guard numberType != "d", numberType != "f" else { return false }
|
||||
return number.intValue == schemaVersion
|
||||
}
|
||||
|
||||
/// Re-checked here rather than trusted: the schema that pins this shape is on the other side of
|
||||
/// a file the native layer cannot see change.
|
||||
static func isServableAssetPath(_ path: String) -> Bool {
|
||||
guard !path.isEmpty, path.utf8.count <= maxAssetPathLength else { return false }
|
||||
for segment in path.split(separator: "/", omittingEmptySubsequences: false) {
|
||||
guard !segment.isEmpty, segment != ".", segment != ".." else { return false }
|
||||
let valid = segment.allSatisfy { character in
|
||||
character.isASCII &&
|
||||
(character.isLetter || character.isNumber || character == "." || character == "_" ||
|
||||
character == "-")
|
||||
}
|
||||
guard valid else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// This value becomes a response header, so it must not be able to carry a second header or a
|
||||
/// parameter we did not intend. One lowercase type, one optional charset: the manifest
|
||||
/// contract's only accepted spelling.
|
||||
static func isServableContentType(_ contentType: String) -> Bool {
|
||||
guard !contentType.isEmpty, contentType.utf8.count <= maxContentTypeLength else { return false }
|
||||
var type = Substring(contentType)
|
||||
if let separator = contentType.range(of: "; charset=") {
|
||||
let charset = contentType[separator.upperBound...]
|
||||
let validCharset = !charset.isEmpty && charset.allSatisfy { character in
|
||||
character.isASCII &&
|
||||
(("a"..."z").contains(character) || ("0"..."9").contains(character) || character == "-")
|
||||
}
|
||||
guard validCharset else { return false }
|
||||
type = contentType[contentType.startIndex..<separator.lowerBound]
|
||||
}
|
||||
let halves = type.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard halves.count == 2 else { return false }
|
||||
return halves.allSatisfy(isMimeToken)
|
||||
}
|
||||
|
||||
private static func isMimeToken(_ token: Substring) -> Bool {
|
||||
guard
|
||||
let first = token.first,
|
||||
first.isASCII,
|
||||
("a"..."z").contains(first) || ("0"..."9").contains(first)
|
||||
else { return false }
|
||||
return token.allSatisfy { character in
|
||||
character.isASCII &&
|
||||
(("a"..."z").contains(character) || ("0"..."9").contains(character) ||
|
||||
character == "." || character == "+" || character == "-")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// The wire names the TypeScript parser accepts; a swap here is a silent change of meaning.
|
||||
enum MobileWebShellFailureReason: String {
|
||||
case generationUnreadable = "generation-unreadable"
|
||||
case isolationUnavailable = "isolation-unavailable"
|
||||
case documentLoadFailed = "document-load-failed"
|
||||
case renderProcessGone = "render-process-gone"
|
||||
}
|
||||
|
||||
struct MobileWebShellLoadEmission: Equatable {
|
||||
let state: String
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
/// What a mount is still allowed to report. A failure is terminal: a rule list can fail to compile
|
||||
/// long after the generation was already refused, and WebKit still reports a navigation outcome
|
||||
/// after a response was cancelled, so without this a second reason or a `ready` lands on top of a
|
||||
/// failure the caller has already acted on. Consecutive duplicates are dropped as well.
|
||||
///
|
||||
/// Pure, and the same rule as the Kotlin copy, so `swiftc` can check it without a device.
|
||||
final class MobileWebShellLoadStateMachine {
|
||||
private var isTerminal = false
|
||||
private var last: MobileWebShellLoadEmission?
|
||||
|
||||
/// Whether a document under the current prop triple has committed. The document a load replaces
|
||||
/// stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the
|
||||
/// directory or the bridge prop changed, so without this it passes every origin check and speaks
|
||||
/// for a load the caller has already been told is `loading`.
|
||||
private(set) var hasCommittedDocument = false
|
||||
|
||||
/// A new prop pair. Nothing else reopens a terminal state: a retry is a remount.
|
||||
func reset() {
|
||||
isTerminal = false
|
||||
last = nil
|
||||
documentEnded()
|
||||
}
|
||||
|
||||
func committed() {
|
||||
guard !isTerminal else { return }
|
||||
hasCommittedDocument = true
|
||||
}
|
||||
|
||||
/// The committed document is gone: a new load, a failure, or a renderer that died.
|
||||
func documentEnded() {
|
||||
hasCommittedDocument = false
|
||||
}
|
||||
|
||||
func started() -> MobileWebShellLoadEmission? {
|
||||
emit(MobileWebShellLoadEmission(state: "loading", reason: nil))
|
||||
}
|
||||
|
||||
func finished() -> MobileWebShellLoadEmission? {
|
||||
emit(MobileWebShellLoadEmission(state: "ready", reason: nil))
|
||||
}
|
||||
|
||||
func failed(_ reason: MobileWebShellFailureReason) -> MobileWebShellLoadEmission? {
|
||||
let emission = emit(MobileWebShellLoadEmission(state: "failed", reason: reason.rawValue))
|
||||
isTerminal = true
|
||||
documentEnded()
|
||||
return emission
|
||||
}
|
||||
|
||||
private func emit(_ emission: MobileWebShellLoadEmission) -> MobileWebShellLoadEmission? {
|
||||
guard !isTerminal, emission != last else { return nil }
|
||||
last = emission
|
||||
return emission
|
||||
}
|
||||
}
|
||||
|
||||
/// A navigation WebKit reports as failed but which is not a failure of the document.
|
||||
///
|
||||
/// `stopLoading` on a prop update, and every navigation the policy delegate refuses, arrive at the
|
||||
/// failure delegates as errors. Reporting those would fail a healthy page, swallow its `ready`, and
|
||||
/// send the caller off to delete a cached generation that is fine.
|
||||
///
|
||||
/// The WebKit constant is written out because the iOS SDK exports no symbol for it: `WKErrorCode`
|
||||
/// stops at the content-rule-list and app-bound-domain errors, and the frame-load codes live in the
|
||||
/// legacy `WebKitErrorDomain`, which WKWebView still reports a policy-cancelled frame load under.
|
||||
enum MobileWebShellNavigationError {
|
||||
static let webKitDomain = "WebKitErrorDomain"
|
||||
static let frameLoadInterruptedByPolicyChange = 102
|
||||
|
||||
static func isIgnorable(domain: String, code: Int) -> Bool {
|
||||
if domain == NSURLErrorDomain, code == NSURLErrorCancelled {
|
||||
return true
|
||||
}
|
||||
return domain == webKitDomain && code == frameLoadInterruptedByPolicyChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
/// The private origin a generation is served from, and the predicate that guards it.
|
||||
///
|
||||
/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc`
|
||||
/// and checks it without a device or a simulator.
|
||||
enum MobileWebShellOrigin {
|
||||
/// A scheme WebKit has no handler for, so the origin shares no cookie jar, cache or storage with
|
||||
/// anything else in the app. A custom scheme's host is opaque, so the session id is used verbatim.
|
||||
static let scheme = "orca-mobile-web"
|
||||
static let maxSessionIdLength = 128
|
||||
static let maxUrlByteCount = 8 * 1024
|
||||
|
||||
static func isValidSessionId(_ sessionId: String) -> Bool {
|
||||
guard !sessionId.isEmpty, sessionId.count <= maxSessionIdLength else { return false }
|
||||
return sessionId.allSatisfy { character in
|
||||
character.isASCII &&
|
||||
(character.isLetter || character.isNumber || character == "-" || character == "_")
|
||||
}
|
||||
}
|
||||
|
||||
/// Host comparison folds case, because a URL parser canonicalises a host and comparing against
|
||||
/// the exact spelling we minted is how the reference lost every asset to a 403. ASCII-only and
|
||||
/// never Unicode: U+212A KELVIN SIGN folds to `k` under `NSString.caseInsensitiveCompare`, which
|
||||
/// would match a host nobody minted against a session id containing `k`.
|
||||
static func asciiLowercased(_ value: String) -> String {
|
||||
var scalars = String.UnicodeScalarView()
|
||||
for scalar in value.unicodeScalars {
|
||||
guard (65...90).contains(scalar.value), let lowered = Unicode.Scalar(scalar.value + 32) else {
|
||||
scalars.append(scalar)
|
||||
continue
|
||||
}
|
||||
scalars.append(lowered)
|
||||
}
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
static func documentUrl(sessionId: String) -> URL? {
|
||||
guard isValidSessionId(sessionId) else { return nil }
|
||||
return URL(string: "\(scheme)://\(sessionId)/")
|
||||
}
|
||||
|
||||
/// The map key for a request we are willing to answer, or nil to refuse. Every clause is an
|
||||
/// allow, so a component nobody anticipated falls to refusal rather than through it.
|
||||
static func resolveRequestPath(
|
||||
_ parts: MobileWebShellRequestParts,
|
||||
sessionId: String
|
||||
) -> String? {
|
||||
guard
|
||||
isValidSessionId(sessionId),
|
||||
parts.method == "GET",
|
||||
!parts.hasRangeHeader,
|
||||
parts.scheme == scheme,
|
||||
let host = parts.host,
|
||||
asciiLowercased(host) == asciiLowercased(sessionId),
|
||||
parts.port == nil,
|
||||
parts.user == nil,
|
||||
parts.query == nil,
|
||||
parts.fragment == nil,
|
||||
parts.urlByteCount <= maxUrlByteCount,
|
||||
!parts.percentEncodedPath.contains("%")
|
||||
else { return nil }
|
||||
if parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/" { return "/" }
|
||||
guard parts.percentEncodedPath.hasPrefix("/") else { return nil }
|
||||
return parts.percentEncodedPath
|
||||
}
|
||||
}
|
||||
|
||||
/// A request reduced to the components the predicate reads, so the predicate needs no WebKit type.
|
||||
struct MobileWebShellRequestParts {
|
||||
var method: String
|
||||
var hasRangeHeader: Bool
|
||||
var scheme: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var user: String?
|
||||
var query: String?
|
||||
var fragment: String?
|
||||
var percentEncodedPath: String
|
||||
var urlByteCount: Int
|
||||
|
||||
init(
|
||||
method: String,
|
||||
hasRangeHeader: Bool,
|
||||
scheme: String?,
|
||||
host: String?,
|
||||
port: Int?,
|
||||
user: String?,
|
||||
query: String?,
|
||||
fragment: String?,
|
||||
percentEncodedPath: String,
|
||||
urlByteCount: Int
|
||||
) {
|
||||
self.method = method
|
||||
self.hasRangeHeader = hasRangeHeader
|
||||
self.scheme = scheme
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.query = query
|
||||
self.fragment = fragment
|
||||
self.percentEncodedPath = percentEncodedPath
|
||||
self.urlByteCount = urlByteCount
|
||||
}
|
||||
|
||||
init?(url: URL, method: String = "GET", hasRangeHeader: Bool = false) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
method: method,
|
||||
hasRangeHeader: hasRangeHeader,
|
||||
scheme: url.scheme,
|
||||
host: url.host,
|
||||
port: url.port,
|
||||
user: url.user,
|
||||
query: url.query,
|
||||
fragment: url.fragment,
|
||||
percentEncodedPath: components.percentEncodedPath,
|
||||
urlByteCount: url.absoluteString.utf8.count
|
||||
)
|
||||
}
|
||||
|
||||
init?(request: URLRequest) {
|
||||
guard let url = request.url else { return nil }
|
||||
self.init(
|
||||
url: url,
|
||||
method: request.httpMethod ?? "GET",
|
||||
hasRangeHeader: request.value(forHTTPHeaderField: "Range") != nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// The headers one served asset answers with.
|
||||
///
|
||||
/// The policy header rides the document and nothing else: on a script or a stylesheet response it
|
||||
/// is inert, and sending it everywhere would hide which response is the one that has to carry it.
|
||||
enum MobileWebShellResponseHeaders {
|
||||
static func forPath(
|
||||
_ path: String,
|
||||
contentType: String,
|
||||
byteCount: Int
|
||||
) -> [String: String] {
|
||||
var headers = [
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(byteCount),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
]
|
||||
if path == "/" {
|
||||
headers["Content-Security-Policy"] = MobileWebShellCsp.header
|
||||
}
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import ExpoModulesCore
|
||||
import WebKit
|
||||
|
||||
private let networkBlockIdentifier = "dev.orca.mobile-web-shell.network-block-v1"
|
||||
|
||||
/// Blocks every http(s) and ws(s) load beneath CSP, at the network layer. A nil compile result is a
|
||||
/// fence we could not install, which is terminal: nothing loads.
|
||||
private let networkBlockRules = """
|
||||
[
|
||||
{ "trigger": { "url-filter": "^https?://" }, "action": { "type": "block" } },
|
||||
{ "trigger": { "url-filter": "^wss?://" }, "action": { "type": "block" } }
|
||||
]
|
||||
"""
|
||||
|
||||
/// CSP is the fence for fetch and XMLHttpRequest. This script exists only for the two things a
|
||||
/// native layer is never shown: a WebSocket handshake, which no request interceptor sees, and a
|
||||
/// service worker registration. Kept in step with the Android copy. `configurable: false` with
|
||||
/// `writable: false` is the only property shape the page cannot put back.
|
||||
private let networkApiBlocker = """
|
||||
(function(){
|
||||
var deny=function(){throw new TypeError('Network access is disabled')};
|
||||
try{Object.defineProperty(globalThis,'WebSocket',{value:deny,configurable:false,writable:false})}catch(_){}
|
||||
try{Object.defineProperty(Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false})}catch(_){}
|
||||
try{Object.defineProperty(navigator,'serviceWorker',{value:undefined,configurable:false,writable:false})}catch(_){}
|
||||
})();
|
||||
"""
|
||||
|
||||
/// Installs `window.orcaBridge`, the whole page-facing surface: `postMessage(json)` and an
|
||||
/// `onmessage` assignment. Android needs no counterpart because `addWebMessageListener` injects an
|
||||
/// object of the same name and shape, so the contract is the intersection of the two.
|
||||
///
|
||||
/// CSP is untouched and the network blocker still runs: this is a second document-start script, not
|
||||
/// a replacement. The sink is captured at install time so a page that deletes `window.webkit`
|
||||
/// cannot take the channel with it, and every property is non-configurable and non-writable, the
|
||||
/// only shape the page cannot put back.
|
||||
private let bridgeInstaller = """
|
||||
(function(){
|
||||
var sink=window.webkit.messageHandlers.orcaBridge;
|
||||
var handler=null;
|
||||
var bridge={};
|
||||
Object.defineProperty(bridge,'postMessage',{value:function(json){
|
||||
if(typeof json!=='string'){throw new TypeError('orcaBridge.postMessage expects a string')}
|
||||
sink.postMessage(json)},configurable:false,writable:false,enumerable:true});
|
||||
Object.defineProperty(bridge,'onmessage',{get:function(){return handler},
|
||||
set:function(value){handler=typeof value==='function'?value:null},configurable:false,enumerable:true});
|
||||
Object.defineProperty(bridge,'__deliver',{value:function(json){if(handler){handler({data:json})}},
|
||||
configurable:false,writable:false,enumerable:false});
|
||||
Object.defineProperty(globalThis,'orcaBridge',{value:bridge,configurable:false,writable:false,enumerable:true});
|
||||
})();
|
||||
"""
|
||||
|
||||
/// The body of a `callAsyncJavaScript` call, with the payload bound to `m` as a real JS value, so no
|
||||
/// reply content is ever parsed as script text.
|
||||
///
|
||||
/// Unguarded on purpose: a missing global is a page the installer never ran in, and throwing is what
|
||||
/// rejects the host's promise. Checking for it would resolve a message nobody received.
|
||||
private let bridgeDeliver = """
|
||||
globalThis.orcaBridge.__deliver(m)
|
||||
"""
|
||||
|
||||
private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
/// An asset is up to 10 MiB, and WebKit starts and stops scheme tasks on the main thread, so the
|
||||
/// read must not happen there.
|
||||
private let readQueue = DispatchQueue(label: "dev.orca.mobile-web-shell.read")
|
||||
/// Delivering to a task WebKit has already stopped raises an Objective-C exception Swift cannot
|
||||
/// catch, so a task is only touched while it is in this set. Main thread only.
|
||||
private var liveTasks: Set<ObjectIdentifier> = []
|
||||
|
||||
var sessionId: String?
|
||||
var generation: MobileWebShellGeneration?
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let key = ObjectIdentifier(urlSchemeTask)
|
||||
liveTasks.insert(key)
|
||||
guard
|
||||
let sessionId,
|
||||
let generation,
|
||||
let url = urlSchemeTask.request.url,
|
||||
let parts = MobileWebShellRequestParts(request: urlSchemeTask.request),
|
||||
let path = MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId),
|
||||
let asset = generation.entries[path]
|
||||
else {
|
||||
fail(urlSchemeTask, key)
|
||||
return
|
||||
}
|
||||
readQueue.async { [weak self] in
|
||||
let data = try? Data(contentsOf: asset.file)
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.liveTasks.contains(key) else { return }
|
||||
guard
|
||||
let data,
|
||||
let response = Self.makeResponse(
|
||||
url: url,
|
||||
asset: asset,
|
||||
byteCount: data.count,
|
||||
path: path
|
||||
)
|
||||
else {
|
||||
self.fail(urlSchemeTask, key)
|
||||
return
|
||||
}
|
||||
self.liveTasks.remove(key)
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
liveTasks.remove(ObjectIdentifier(urlSchemeTask))
|
||||
}
|
||||
|
||||
private func fail(_ urlSchemeTask: WKURLSchemeTask, _ key: ObjectIdentifier) {
|
||||
guard liveTasks.remove(key) != nil else { return }
|
||||
urlSchemeTask.didFailWithError(URLError(.resourceUnavailable))
|
||||
}
|
||||
|
||||
private static func makeResponse(
|
||||
url: URL,
|
||||
asset: MobileWebShellAsset,
|
||||
byteCount: Int,
|
||||
path: String
|
||||
) -> HTTPURLResponse? {
|
||||
HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: MobileWebShellResponseHeaders.forPath(
|
||||
path,
|
||||
contentType: asset.contentType,
|
||||
byteCount: byteCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `WKUserContentController` retains its message handlers, so the back-reference has to be weak or
|
||||
/// the view outlives the React element that owned it.
|
||||
private final class MobileWebShellBridgeReceiver: NSObject, WKScriptMessageHandler {
|
||||
weak var view: OrcaMobileWebShellView?
|
||||
|
||||
func userContentController(
|
||||
_ controller: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
view?.receiveBridgeMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// The RN host sees this, never the page: it is the difference between a request that failed and
|
||||
/// one that never settles.
|
||||
internal final class MobileWebShellBridgeDeliveryFailedException: GenericException<String>,
|
||||
@unchecked Sendable {
|
||||
override var reason: String {
|
||||
"The mobile web shell bridge could not deliver a message: \(param)"
|
||||
}
|
||||
}
|
||||
|
||||
internal final class MobileWebShellBridgeUnavailableException: Exception, @unchecked Sendable {
|
||||
override var reason: String {
|
||||
"The mobile web shell bridge is not installed on this view"
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown rather than dropped: the only caller is the React Native host, and a silent drop would
|
||||
/// turn a chunking bug there into a request that never settles.
|
||||
internal final class MobileWebShellBridgeMessageTooLargeException: GenericException<Int>,
|
||||
@unchecked Sendable {
|
||||
override var reason: String {
|
||||
"A bridge message of \(param) bytes exceeds the \(MobileWebShellBridge.maxMessageByteCount) byte cap"
|
||||
}
|
||||
}
|
||||
|
||||
final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate {
|
||||
let onLoadState = EventDispatcher()
|
||||
let onBridgeMessage = EventDispatcher()
|
||||
|
||||
private let schemeHandler = MobileWebShellSchemeHandler()
|
||||
private let bridgeReceiver = MobileWebShellBridgeReceiver()
|
||||
private let bridgeGate = MobileWebShellBridgeGate()
|
||||
private var bridgeEnabled = false
|
||||
private var bridgeInstalled = false
|
||||
private var bridgeTarget = MobileWebShellBridgeTarget<WKFrameInfo>()
|
||||
private var webView: WKWebView!
|
||||
private var generationDirectory = ""
|
||||
private var sessionId = ""
|
||||
private var applied: MobileWebShellAppliedProps?
|
||||
private var appliedSessionId: String? { applied?.sessionId }
|
||||
private var pendingDocumentUrl: URL?
|
||||
private var isolationReady = false
|
||||
private var isolationFailed = false
|
||||
private let loadState = MobileWebShellLoadStateMachine()
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
let configuration = WKWebViewConfiguration()
|
||||
// DOM storage and databases cannot be switched off on WebKit. A non-persistent store plus a
|
||||
// per-session origin plus destruction on unmount is the whole mitigation, and no isolation
|
||||
// claim here rests on them being absent.
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme)
|
||||
configuration.userContentController.addUserScript(Self.makeBlockerScript())
|
||||
bridgeReceiver.view = self
|
||||
webView = WKWebView(frame: bounds, configuration: configuration)
|
||||
webView.navigationDelegate = self
|
||||
webView.uiDelegate = self
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(webView)
|
||||
NSLayoutConstraint.activate([
|
||||
webView.topAnchor.constraint(equalTo: topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
])
|
||||
installNetworkBlock(into: configuration.userContentController)
|
||||
}
|
||||
|
||||
func setGenerationDirectory(_ value: String) {
|
||||
generationDirectory = value
|
||||
}
|
||||
|
||||
func setSessionId(_ value: String) {
|
||||
sessionId = value
|
||||
}
|
||||
|
||||
func setBridgeEnabled(_ value: Bool) {
|
||||
bridgeEnabled = value
|
||||
}
|
||||
|
||||
/// Props arrive in no defined order, so neither setter starts anything; this does, once both are
|
||||
/// in. A repeat of the same triple is not a retry: a retry is a remount under a new React key.
|
||||
/// `bridgeEnabled` is in the record because a document-start script only takes effect at the next
|
||||
/// document start: toggling it has to reload, or the prop would silently do nothing.
|
||||
func propsDidUpdate() {
|
||||
let next = MobileWebShellAppliedProps(
|
||||
generationDirectory: generationDirectory,
|
||||
sessionId: sessionId,
|
||||
bridgeEnabled: bridgeEnabled
|
||||
)
|
||||
guard applied?.matches(next) != true else { return }
|
||||
applied = next
|
||||
clearBridgeTarget()
|
||||
loadState.reset()
|
||||
pendingDocumentUrl = nil
|
||||
webView.stopLoading()
|
||||
webView.isHidden = false
|
||||
emit(loadState.started())
|
||||
guard
|
||||
MobileWebShellOrigin.isValidSessionId(sessionId),
|
||||
let documentUrl = MobileWebShellOrigin.documentUrl(sessionId: sessionId)
|
||||
else {
|
||||
// The private origin is the isolation primitive; a malformed session id leaves us without one.
|
||||
failPropUpdate(.isolationUnavailable)
|
||||
return
|
||||
}
|
||||
guard
|
||||
let generation = try? MobileWebShellGeneration.load(directoryPath: generationDirectory)
|
||||
else {
|
||||
failPropUpdate(.generationUnreadable)
|
||||
return
|
||||
}
|
||||
schemeHandler.sessionId = sessionId
|
||||
schemeHandler.generation = generation
|
||||
applyBridgeInstallation()
|
||||
if isolationFailed {
|
||||
failPropUpdate(.isolationUnavailable)
|
||||
return
|
||||
}
|
||||
pendingDocumentUrl = documentUrl
|
||||
loadWhenIsolated()
|
||||
}
|
||||
|
||||
/// The generation that failed to apply replaces whatever was on screen; leaving the previous one
|
||||
/// served and visible would show a page the caller has just been told is not loaded.
|
||||
private func failPropUpdate(_ reason: MobileWebShellFailureReason) {
|
||||
clearBridgeTarget()
|
||||
schemeHandler.sessionId = nil
|
||||
schemeHandler.generation = nil
|
||||
pendingDocumentUrl = nil
|
||||
webView.stopLoading()
|
||||
webView.isHidden = true
|
||||
emit(loadState.failed(reason))
|
||||
}
|
||||
|
||||
/// Rebuilt per install rather than stored: `removeAllUserScripts` is the only removal WebKit has,
|
||||
/// so uninstalling the bridge means re-adding the blocker.
|
||||
private static func makeBlockerScript() -> WKUserScript {
|
||||
WKUserScript(
|
||||
source: networkApiBlocker,
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Nothing here runs while the prop stays false, which is what keeps Phase B byte-identical.
|
||||
private func applyBridgeInstallation() {
|
||||
guard bridgeEnabled != bridgeInstalled else { return }
|
||||
clearBridgeTarget()
|
||||
let controller = webView.configuration.userContentController
|
||||
if bridgeEnabled {
|
||||
controller.add(bridgeReceiver, name: MobileWebShellBridge.handlerName)
|
||||
controller.addUserScript(
|
||||
WKUserScript(
|
||||
source: bridgeInstaller,
|
||||
injectionTime: .atDocumentStart,
|
||||
// A convenience, not the fence: a subframe can reach a handler this never ran in, and
|
||||
// `accepts` is what refuses it.
|
||||
forMainFrameOnly: true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
controller.removeScriptMessageHandler(forName: MobileWebShellBridge.handlerName)
|
||||
controller.removeAllUserScripts()
|
||||
controller.addUserScript(Self.makeBlockerScript())
|
||||
}
|
||||
bridgeInstalled = bridgeEnabled
|
||||
}
|
||||
|
||||
/// The session the page was loaded under, not the latest prop: a document served under the
|
||||
/// previous one is still alive until the next load commits, and it must not be heard.
|
||||
fileprivate func receiveBridgeMessage(_ message: WKScriptMessage) {
|
||||
guard bridgeInstalled, let json = message.body as? String else { return }
|
||||
let origin = message.frameInfo.securityOrigin
|
||||
let source = MobileWebShellBridgeSource(
|
||||
isOurWebView: message.webView === webView,
|
||||
isMainFrame: message.frameInfo.isMainFrame,
|
||||
hasCommittedDocument: loadState.hasCommittedDocument,
|
||||
originProtocol: origin.`protocol`,
|
||||
originHost: origin.host
|
||||
)
|
||||
guard
|
||||
MobileWebShellBridge.accepts(source, sessionId: appliedSessionId ?? ""),
|
||||
bridgeGate.accepts(byteCount: json.utf8.count)
|
||||
else { return }
|
||||
bridgeTarget.arm(frame: message.frameInfo, originHost: origin.host)
|
||||
onBridgeMessage(["json": json])
|
||||
}
|
||||
|
||||
/// Anything that ends the document the page spoke from ends the only target native has.
|
||||
private func clearBridgeTarget() {
|
||||
bridgeTarget.clear()
|
||||
}
|
||||
|
||||
/// Settles on what WebKit did, not on what we handed it: a post into a dead renderer, a document
|
||||
/// that failed to load, a navigation still in flight or a page that has never spoken rejects here,
|
||||
/// and the delivery itself resolves only once the page has run it. Resolving any of those
|
||||
/// optimistically turns a request the RN host is waiting on into one that never settles.
|
||||
func postBridgeMessage(_ json: String, promise: Promise) throws {
|
||||
guard
|
||||
MobileWebShellBridge.canPost(
|
||||
toFrameOriginHost: bridgeTarget.originHost,
|
||||
sessionId: appliedSessionId ?? "",
|
||||
hasCommittedDocument: loadState.hasCommittedDocument
|
||||
),
|
||||
let frame = bridgeTarget.frame
|
||||
else {
|
||||
throw MobileWebShellBridgeUnavailableException()
|
||||
}
|
||||
let byteCount = json.utf8.count
|
||||
guard MobileWebShellBridge.acceptsByteCount(byteCount) else {
|
||||
throw MobileWebShellBridgeMessageTooLargeException(byteCount)
|
||||
}
|
||||
// Two `in:` labels is the real signature: `in frame:` and `in contentWorld:`. Naming the
|
||||
// completion handler is what picks it over the `async` overload. The frame is the one that
|
||||
// spoke, so the reply goes where the request came from rather than to the current main frame.
|
||||
webView.callAsyncJavaScript(
|
||||
bridgeDeliver,
|
||||
arguments: ["m": json],
|
||||
in: frame,
|
||||
in: .page
|
||||
) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
promise.resolve()
|
||||
case .failure(let error):
|
||||
promise.reject(MobileWebShellBridgeDeliveryFailedException(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installNetworkBlock(into controller: WKUserContentController) {
|
||||
guard let store = WKContentRuleListStore.default() else {
|
||||
// Optional-chaining past this ran no completion handler at all, so the view sat at `loading`
|
||||
// for the rest of its life. No store is no fence, which is the same terminal answer.
|
||||
isolationFailed = true
|
||||
pendingDocumentUrl = nil
|
||||
return
|
||||
}
|
||||
store.compileContentRuleList(
|
||||
forIdentifier: networkBlockIdentifier,
|
||||
encodedContentRuleList: networkBlockRules
|
||||
) { [weak self] ruleList, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
guard let ruleList else {
|
||||
self.isolationFailed = true
|
||||
self.pendingDocumentUrl = nil
|
||||
// Compiling is asynchronous, so this can land after the generation was already refused;
|
||||
// the state machine is what keeps that from being a second terminal reason.
|
||||
if self.appliedSessionId != nil {
|
||||
self.failPropUpdate(.isolationUnavailable)
|
||||
}
|
||||
return
|
||||
}
|
||||
controller.add(ruleList)
|
||||
self.isolationReady = true
|
||||
self.loadWhenIsolated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadWhenIsolated() {
|
||||
guard isolationReady, let url = pendingDocumentUrl else { return }
|
||||
pendingDocumentUrl = nil
|
||||
webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData))
|
||||
}
|
||||
|
||||
private func emit(_ emission: MobileWebShellLoadEmission?) {
|
||||
guard let emission else { return }
|
||||
var payload: [String: Any] = ["state": emission.state]
|
||||
if let reason = emission.reason {
|
||||
payload["reason"] = reason
|
||||
}
|
||||
onLoadState(payload)
|
||||
}
|
||||
|
||||
private func reportDocumentFailure() {
|
||||
clearBridgeTarget()
|
||||
emit(loadState.failed(.documentLoadFailed))
|
||||
}
|
||||
|
||||
/// A cancelled navigation is our own doing, not the document's; see MobileWebShellNavigationError.
|
||||
private func reportNavigationFailure(_ error: Error) {
|
||||
let error = error as NSError
|
||||
guard !MobileWebShellNavigationError.isIgnorable(domain: error.domain, code: error.code) else {
|
||||
return
|
||||
}
|
||||
reportDocumentFailure()
|
||||
}
|
||||
|
||||
private func isDocumentUrl(_ url: URL?) -> Bool {
|
||||
guard let url, let parts = MobileWebShellRequestParts(url: url) else { return false }
|
||||
return MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId) == "/"
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
if #available(iOS 14.5, *), navigationAction.shouldPerformDownload {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
let allowed = navigationAction.targetFrame?.isMainFrame == true &&
|
||||
isDocumentUrl(navigationAction.request.url)
|
||||
decisionHandler(allowed ? .allow : .cancel)
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||||
) {
|
||||
let allowed = navigationResponse.isForMainFrame &&
|
||||
navigationResponse.canShowMIMEType &&
|
||||
isDocumentUrl(navigationResponse.response.url)
|
||||
if !allowed {
|
||||
reportDocumentFailure()
|
||||
}
|
||||
decisionHandler(allowed ? .allow : .cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
// The document that spoke is being replaced, so it stops being somewhere to post and stops
|
||||
// being someone to hear: the next one has to commit, then say `ready`, which is what the
|
||||
// envelope has it do.
|
||||
clearBridgeTarget()
|
||||
loadState.documentEnded()
|
||||
guard appliedSessionId != nil else { return }
|
||||
emit(loadState.started())
|
||||
}
|
||||
|
||||
/// The load the caller was told about is the one now on screen, so this is where the page becomes
|
||||
/// something to hear. Earlier than `didFinish`, because the page speaks at document start.
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
guard isDocumentUrl(webView.url) else { return }
|
||||
// Cleared here too, not only at the provisional start: arming is what this re-opens, so the
|
||||
// frame the replaced document spoke from must not be inheritable by the one replacing it.
|
||||
clearBridgeTarget()
|
||||
loadState.committed()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard isDocumentUrl(webView.url) else { return }
|
||||
emit(loadState.finished())
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
didFailProvisionalNavigation navigation: WKNavigation!,
|
||||
withError error: Error
|
||||
) {
|
||||
reportNavigationFailure(error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
reportNavigationFailure(error)
|
||||
}
|
||||
|
||||
/// Reported, never recovered from here. Renderer memory pressure and a WebView provider update
|
||||
/// look identical at this point, so the retry policy is the caller's and lives in one place.
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
clearBridgeTarget()
|
||||
emit(loadState.failed(.renderProcessGone))
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for navigationAction: WKNavigationAction,
|
||||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'OrcaMobileWebShell'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'WebView shell that serves one generation directory from a private origin'
|
||||
s.description = s.summary
|
||||
s.license = { :type => 'MIT' }
|
||||
s.author = 'Orca'
|
||||
s.homepage = 'https://onorca.dev'
|
||||
s.source = { :git => 'https://github.com/stablyai/orca.git' }
|
||||
s.platforms = { :ios => '15.1' }
|
||||
s.swift_version = '5.9'
|
||||
s.static_framework = true
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.source_files = '**/*.swift'
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class OrcaMobileWebShellModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("OrcaMobileWebShell")
|
||||
|
||||
View(OrcaMobileWebShellView.self) {
|
||||
Events("onLoadState", "onBridgeMessage")
|
||||
|
||||
Prop("generationDirectory") { (view: OrcaMobileWebShellView, value: String) in
|
||||
view.setGenerationDirectory(value)
|
||||
}
|
||||
|
||||
Prop("sessionId") { (view: OrcaMobileWebShellView, value: String) in
|
||||
view.setSessionId(value)
|
||||
}
|
||||
|
||||
Prop("bridgeEnabled") { (view: OrcaMobileWebShellView, value: Bool) in
|
||||
view.setBridgeEnabled(value)
|
||||
}
|
||||
|
||||
AsyncFunction("postBridgeMessage") {
|
||||
(view: OrcaMobileWebShellView, json: String, promise: Promise) in
|
||||
try view.postBridgeMessage(json, promise: promise)
|
||||
}
|
||||
|
||||
OnViewDidUpdateProps { (view: OrcaMobileWebShellView) in
|
||||
view.propsDidUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "orca-mobile-web-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { requireNativeViewManager } from 'expo-modules-core'
|
||||
import type { ComponentType, RefAttributes } from 'react'
|
||||
import type { NativeSyntheticEvent, ViewProps } from 'react-native'
|
||||
import type { MobileWebShellLoadStatePayload } from './load-state'
|
||||
|
||||
/** One raw JSON envelope, exactly as the page posted it. Parsing is the caller's. */
|
||||
export type MobileWebShellBridgeMessagePayload = { json: string }
|
||||
|
||||
export type OrcaMobileWebShellViewProps = ViewProps & {
|
||||
/**
|
||||
* Absolute path of an activated generation directory: `index.html`, `manifest.json`, and
|
||||
* `assets/<sha256>.<ext>`. The TypeScript store owns it and has already verified every byte;
|
||||
* the view only reads, and never from a path the page can influence.
|
||||
*/
|
||||
generationDirectory: string
|
||||
/** `[A-Za-z0-9_-]{1,128}`. Scopes the private origin, so every mount must mint a fresh one. */
|
||||
sessionId: string
|
||||
/**
|
||||
* Off unless asked for: with it false nothing is registered on either platform, so the view
|
||||
* behaves exactly as it did before the bridge existed. On Android a provider older than
|
||||
* `WEB_MESSAGE_LISTENER` (Chromium 88) reports `isolation-unavailable` rather than loading
|
||||
* without a channel, and only when this is true.
|
||||
*/
|
||||
bridgeEnabled?: boolean
|
||||
onLoadState?: (event: NativeSyntheticEvent<MobileWebShellLoadStatePayload>) => void
|
||||
/**
|
||||
* The page posted `json` through `window.orcaBridge`. Native has already refused anything from
|
||||
* another origin, another frame or another WebView, and anything over the 640 KiB cap
|
||||
* (`MobileWebShellBridge.maxMessageByteCount`); a refusal is silent and reaches no event.
|
||||
*/
|
||||
onBridgeMessage?: (event: NativeSyntheticEvent<MobileWebShellBridgeMessagePayload>) => void
|
||||
}
|
||||
|
||||
/** What a ref on the view carries. Expo puts the view's functions on the component prototype. */
|
||||
export type OrcaMobileWebShellViewHandle = {
|
||||
/**
|
||||
* Delivers one raw JSON envelope to the page. Rejects when the message is over the cap, and when
|
||||
* there is nowhere to post: no page has spoken since the last load, a navigation is in flight,
|
||||
* the load failed, or the renderer is gone. The caller is the host, so a silent drop is a request
|
||||
* that never settles.
|
||||
*
|
||||
* Delivery is never proven by resolve. iOS rejects the failures it is told about, because
|
||||
* `callAsyncJavaScript` reports whether the page ran the delivery; Android cannot, because
|
||||
* `JavaScriptReplyProxy.postMessage` is void and has no acknowledgement, so resolve there means
|
||||
* enqueued rather than delivered. Anything that must know the page received a message has to
|
||||
* hear that from the page.
|
||||
*/
|
||||
postBridgeMessage: (json: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one generation directory in a WebView served from a private origin. There is no reload:
|
||||
* a retry is a remount under a new React key, which rebuilds the WebView and reinstalls every
|
||||
* fence. The only imperative call is `postBridgeMessage`, and it can say nothing about the load.
|
||||
*/
|
||||
export const OrcaMobileWebShellView: ComponentType<
|
||||
OrcaMobileWebShellViewProps & RefAttributes<OrcaMobileWebShellViewHandle>
|
||||
> = requireNativeViewManager<
|
||||
OrcaMobileWebShellViewProps & RefAttributes<OrcaMobileWebShellViewHandle>
|
||||
>('OrcaMobileWebShell')
|
||||
|
||||
export {
|
||||
MOBILE_WEB_SHELL_FAILURE_REASONS,
|
||||
parseMobileWebShellLoadState,
|
||||
type MobileWebShellFailureReason,
|
||||
type MobileWebShellLoadState,
|
||||
type MobileWebShellLoadStatePayload
|
||||
} from './load-state'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user