feat(memory-safety): bound OOM-prone ingress across transports, parsers and scans

Re-scoped from the reverted #10179 to the bound-at-ingress doctrine (see
src/shared/memory-safety/README.md): ceilings go where untrusted or
unpredictably-large input first enters a heap, not in the interior.

Kept: daemon/relay/SSH/mobile/IPC transport frames, PTY output flow control,
on-disk agent transcripts, document parsing, filesystem enumeration, provider
pagination, media decode, and long-lived accumulators keyed by user-growable
ids -- the class behind the reported renderer OOM (#9872).

Dropped: interior ceilings whose size is already implied by a bounded input,
second ceilings behind existing ones, and derived/presentational state.

Every file is reconciled against current main: check-no-merge-base-revert
reports 0 reverted and 0 missing across 169 overlapping files, so no shipped
work is lost. Typecheck is at main's own 2-error baseline.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-25 23:43:37 -07:00
co-authored by Orca
parent 7fd4b2236a
commit 4be5bcd756
1272 changed files with 83735 additions and 20788 deletions
+4
View File
@@ -117,6 +117,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
@@ -158,6 +159,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
linux:
@@ -185,6 +187,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
@@ -204,4 +207,5 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
+8
View File
@@ -113,6 +113,14 @@ jobs:
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
# Why: a branch built by overwriting files (instead of merging) silently reverts shipped work.
# Typecheck and tests cannot see it — a reverted feature still compiles and its old tests pass.
- name: Check for reverted merge-base work
if: github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=200 origin "${{ github.base_ref }}"
node config/scripts/check-no-merge-base-revert.mjs --base "origin/${{ github.base_ref }}" --head HEAD
- name: Typecheck
run: pnpm typecheck
+62 -252
View File
@@ -1,6 +1,5 @@
{
"allowed": {
"mobile/app/h/[hostId]/session/[worktreeId].tsx:json-stringify": 1,
"mobile/app/index.tsx:json-parse": 1,
"mobile/src/cache/home-snapshot-cache.ts:json-parse": 1,
"mobile/src/cache/home-snapshot-cache.ts:json-stringify": 1,
@@ -11,9 +10,10 @@
"mobile/src/components/pr-sidebar/MermaidDiagram.tsx:json-stringify": 1,
"mobile/src/files/mobile-file-preview-source.ts:json-stringify": 2,
"mobile/src/session/MobileNativeChatView.tsx:json-stringify": 1,
"mobile/src/session/mobile-image-source-picker.ts:response-text": 1,
"mobile/src/session/mobile-native-chat-permission.ts:json-parse": 1,
"mobile/src/session/mobile-native-chat-question.ts:json-stringify": 1,
"mobile/src/session/mobile-session-file-doc-lifecycle.ts:json-stringify": 1,
"mobile/src/session/mobile-session-markdown-doc-lifecycle.ts:json-stringify": 1,
"mobile/src/session/mobile-terminal-records.ts:json-stringify": 4,
"mobile/src/session/use-mobile-native-chat-ask-dismiss.ts:json-stringify": 1,
"mobile/src/storage/codex-reset-attempt-journal.ts:json-parse": 1,
@@ -22,6 +22,7 @@
"mobile/src/storage/preferences.ts:json-stringify": 2,
"mobile/src/storage/session-view-preferences.ts:json-parse": 1,
"mobile/src/storage/session-view-preferences.ts:json-stringify": 1,
"mobile/src/tasks/mobile-pr-file-content-cache.ts:json-stringify": 1,
"mobile/src/terminal/TerminalWebView.tsx:json-parse": 1,
"mobile/src/terminal/TerminalWebView.tsx:json-stringify": 2,
"mobile/src/terminal/terminal-accessory-layout.ts:json-parse": 1,
@@ -29,51 +30,33 @@
"mobile/src/terminal/terminal-webview-html.ts:json-parse": 1,
"mobile/src/terminal/terminal-webview-html.ts:json-stringify": 4,
"mobile/src/terminal/terminal-webview-url-tap.ts:json-stringify": 2,
"mobile/src/transport/browser-screencast-protocol.ts:json-parse": 1,
"mobile/src/transport/host-credential-cleanup.ts:json-parse": 1,
"mobile/src/transport/host-credential-cleanup.ts:json-stringify": 1,
"mobile/src/transport/host-store.ts:json-parse": 1,
"mobile/src/transport/host-store.ts:json-stringify": 1,
"mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-parse": 3,
"mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-stringify": 2,
"mobile/src/transport/mobile-relay-credential-bundle.ts:json-parse": 1,
"mobile/src/transport/mobile-direct-rpc-outbound.ts:json-parse": 1,
"mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-stringify": 1,
"mobile/src/transport/mobile-host-list-storage.ts:json-stringify": 1,
"mobile/src/transport/mobile-json-text-admission.ts:json-parse": 1,
"mobile/src/transport/mobile-outbound-json.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-credential-bundle.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-credential-rotation.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:json-parse": 1,
"mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-direct-upgrade.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-e2ee-link.ts:json-parse": 1,
"mobile/src/transport/mobile-relay-e2ee-link.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-host-overlay-store.ts:json-parse": 1,
"mobile/src/transport/mobile-relay-host-overlay-store.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-invite-director.ts:json-parse": 1,
"mobile/src/transport/mobile-relay-invite-director.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-parse": 2,
"mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-stringify": 3,
"mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-stringify": 2,
"mobile/src/transport/mobile-relay-pairing-journal.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-pairing-recovery.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-physical-client.ts:json-parse": 2,
"mobile/src/transport/mobile-relay-physical-client.ts:json-stringify": 2,
"mobile/src/transport/mobile-relay-resume-director.ts:json-parse": 1,
"mobile/src/transport/mobile-relay-physical-client.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-resume-director.ts:json-stringify": 1,
"mobile/src/transport/mobile-relay-resume-director.ts:response-text": 1,
"mobile/src/transport/mobile-relay-rpc-session.ts:json-parse": 1,
"mobile/src/transport/mobile-relay-rpc-session.ts:json-stringify": 1,
"mobile/src/transport/pairing.ts:json-parse": 1,
"mobile/src/transport/pre-profile-pairing-coordinator.ts:json-stringify": 1,
"mobile/src/transport/rpc-client.ts:json-parse": 3,
"mobile/src/transport/rpc-client.ts:json-stringify": 2,
"mobile/src/transport/rpc-client.ts:json-stringify": 1,
"mobile/src/transport/socket-event-debug.ts:json-stringify": 1,
"mobile/src/transport/terminal-stream-protocol.ts:json-parse": 1,
"mobile/src/transport/websocket-payload-bytes.ts:response-text": 1,
"mobile/src/worktree/last-visited-worktree-repo.ts:json-parse": 1,
"src/cli/bundled-skill-guides.ts:json-parse": 1,
"src/cli/bundled-skill-guides.ts:json-stringify": 1,
"src/cli/bundled-skill-guides.ts:read-file": 1,
"src/cli/format.ts:json-stringify": 3,
"src/cli/handlers/agent-hooks.ts:json-parse": 1,
"src/cli/handlers/agent-hooks.ts:json-stringify": 1,
"src/cli/handlers/agent-hooks.ts:read-file": 1,
"src/cli/handlers/automations.ts:json-parse": 1,
"src/cli/handlers/browser-env.ts:json-stringify": 1,
"src/cli/handlers/browser-interact.ts:json-stringify": 2,
@@ -86,8 +69,6 @@
"src/cli/handlers/orchestration.ts:json-stringify": 3,
"src/cli/handlers/skills.ts:json-stringify": 2,
"src/cli/handlers/vm.ts:json-stringify": 1,
"src/cli/handlers/vm.ts:read-file": 1,
"src/cli/linear-request-builders.ts:read-file": 1,
"src/cli/runtime/mac-app-update-bundle.ts:read-file": 1,
"src/cli/runtime/metadata.ts:json-parse": 2,
"src/cli/runtime/metadata.ts:read-file": 2,
@@ -100,83 +81,42 @@
"src/main/active-view-preference.ts:json-parse": 1,
"src/main/active-view-preference.ts:json-stringify": 1,
"src/main/active-view-preference.ts:read-file": 1,
"src/main/agent-hooks/agent-hook-sftp-text-reader.ts:read-file": 1,
"src/main/agent-hooks/hooks-json-read.ts:json-parse": 1,
"src/main/agent-hooks/hooks-json-read.ts:read-file": 1,
"src/main/agent-hooks/install-telemetry.ts:json-stringify": 1,
"src/main/agent-hooks/installer-utils-remote.ts:json-parse": 1,
"src/main/agent-hooks/installer-utils-remote.ts:json-stringify": 1,
"src/main/agent-hooks/installer-utils-remote.ts:read-file": 7,
"src/main/agent-hooks/installer-utils-remote.ts:read-file": 6,
"src/main/agent-hooks/installer-utils.ts:json-stringify": 1,
"src/main/agent-hooks/installer-utils.ts:read-file": 2,
"src/main/agent-hooks/managed-hook-local-filesystem.ts:read-file": 2,
"src/main/agent-hooks/managed-hook-lock-claims.ts:json-stringify": 1,
"src/main/agent-hooks/managed-hook-lock-claims.ts:read-file": 2,
"src/main/agent-hooks/managed-hook-lock-records.ts:json-parse": 3,
"src/main/agent-hooks/managed-hook-lock-records.ts:json-stringify": 1,
"src/main/agent-hooks/managed-hook-lock-records.ts:read-file": 3,
"src/main/agent-hooks/managed-hook-lock-records.ts:unbounded-fanout": 1,
"src/main/agent-hooks/managed-hook-owner-identity.ts:read-file": 3,
"src/main/agent-hooks/managed-hook-owner-identity.ts:read-file": 2,
"src/main/agent-hooks/server.ts:json-parse": 1,
"src/main/agent-hooks/server.ts:json-stringify": 1,
"src/main/agent-hooks/server.ts:read-file": 1,
"src/main/agent-hooks/wsl-hook-fs-adapter.ts:read-file": 1,
"src/main/agent-hooks/wsl-hook-relay-deps.ts:read-file": 1,
"src/main/agent-hooks/wsl-hook-relay-launch.ts:read-file": 1,
"src/main/agent-state-file-reader.ts:json-parse": 1,
"src/main/agent-trust-presets.ts:json-parse": 1,
"src/main/agent-trust-presets.ts:json-stringify": 2,
"src/main/agent-trust-presets.ts:read-file": 3,
"src/main/ai-vault/cached-session-list.ts:json-stringify": 1,
"src/main/ai-vault/remote-session-scanner-codex-index.ts:read-file": 1,
"src/main/ai-vault/remote-session-scanner-discovery.ts:unbounded-fanout": 1,
"src/main/ai-vault/remote-session-scanner.ts:read-file": 2,
"src/main/ai-vault/remote-session-scanner.ts:unbounded-fanout": 3,
"src/main/ai-vault/session-parse-cache-persistence.ts:json-parse": 1,
"src/main/ai-vault/session-parse-cache-persistence.ts:json-stringify": 1,
"src/main/ai-vault/session-parse-cache-persistence.ts:read-file": 1,
"src/main/ai-vault/session-scanner-antigravity-fixtures.ts:json-stringify": 1,
"src/main/ai-vault/session-scanner-claude-subagents.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-claude-subagents.ts:read-file": 1,
"src/main/ai-vault/session-scanner-claude-subagents.ts:unbounded-fanout": 1,
"src/main/ai-vault/session-scanner-codex-fixtures.ts:json-stringify": 1,
"src/main/ai-vault/session-scanner-devin-parser.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-devin-parser.ts:read-file": 1,
"src/main/ai-vault/session-scanner-discovery.ts:unbounded-fanout": 1,
"src/main/ai-vault/session-scanner-gemini-parsers.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-gemini-parsers.ts:read-file": 1,
"src/main/ai-vault/session-scanner-graph-parsers.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-graph-parsers.ts:read-file": 1,
"src/main/ai-vault/session-scanner-grok-parser.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-grok-parser.ts:read-file": 1,
"src/main/ai-vault/session-scanner-incremental-fixtures.ts:json-stringify": 25,
"src/main/ai-vault/session-scanner-kimi-parser.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-kimi-parser.ts:read-file": 1,
"src/main/ai-vault/session-scanner-kimi-paths.ts:json-parse": 1,
"src/main/ai-vault/session-scanner-opencode-sqlite.ts:json-parse": 2,
"src/main/ai-vault/session-scanner-secondary-parsers.ts:json-parse": 3,
"src/main/ai-vault/session-scanner-secondary-parsers.ts:read-file": 3,
"src/main/ai-vault/session-scanner-test-fixtures.ts:json-stringify": 1,
"src/main/ai-vault/session-scanner-values.ts:json-parse": 2,
"src/main/ai-vault/session-scanner-values.ts:read-file": 1,
"src/main/ai-vault/session-scanner.ts:read-file": 1,
"src/main/ai-vault/session-scanner-values.ts:json-parse": 1,
"src/main/ai-vault/session-scanner.ts:unbounded-fanout": 1,
"src/main/amp/hook-service.ts:json-stringify": 2,
"src/main/amp/hook-service.ts:read-file": 3,
"src/main/attribution/terminal-attribution.ts:read-file": 1,
"src/main/automations/external-manager.ts:json-parse": 2,
"src/main/automations/external-manager.ts:read-file": 2,
"src/main/automations/external-manager.ts:unbounded-fanout": 1,
"src/main/automations/hermes-cron-output.ts:read-file": 2,
"src/main/automations/hermes-cron-output.ts:unbounded-fanout": 1,
"src/main/azure-devops/azure-devops-api-request.ts:response-text": 1,
"src/main/azure-devops/pull-request-creation.ts:json-stringify": 1,
"src/main/bitbucket/client.ts:response-text": 1,
"src/main/browser/agent-browser-bridge.ts:json-parse": 2,
"src/main/browser/agent-browser-bridge.ts:json-stringify": 9,
"src/main/browser/agent-browser-bridge.ts:read-file": 1,
"src/main/browser/browser-clicked-link-routing.ts:json-stringify": 2,
"src/main/browser/browser-cookie-import.ts:json-parse": 3,
"src/main/browser/browser-cookie-import.ts:read-file": 4,
"src/main/browser/browser-cookie-import.ts:json-parse": 2,
"src/main/browser/browser-manager.ts:json-stringify": 24,
"src/main/browser/browser-session-registry.ts:json-parse": 1,
"src/main/browser/browser-session-registry.ts:json-stringify": 1,
@@ -193,29 +133,24 @@
"src/main/claude-accounts/managed-auth-path.ts:read-file": 2,
"src/main/claude-accounts/oauth-refresh.ts:json-parse": 2,
"src/main/claude-accounts/oauth-refresh.ts:json-stringify": 1,
"src/main/claude-accounts/oauth-refresh.ts:response-text": 1,
"src/main/claude-accounts/runtime-auth-service.ts:json-parse": 8,
"src/main/claude-accounts/runtime-auth-service.ts:json-parse": 5,
"src/main/claude-accounts/runtime-auth-service.ts:json-stringify": 3,
"src/main/claude-accounts/runtime-auth-service.ts:read-file": 10,
"src/main/claude-accounts/service.ts:json-parse": 2,
"src/main/claude-accounts/service.ts:json-parse": 1,
"src/main/claude-accounts/service.ts:json-stringify": 2,
"src/main/claude-accounts/service.ts:read-file": 2,
"src/main/claude-usage/scanner.ts:json-parse": 1,
"src/main/claude-usage/scanner.ts:unbounded-fanout": 3,
"src/main/claude-usage/scanner.ts:unbounded-fanout": 2,
"src/main/claude-usage/store.ts:json-parse": 1,
"src/main/claude-usage/store.ts:json-stringify": 3,
"src/main/claude-usage/store.ts:read-file": 1,
"src/main/claude/hook-settings.ts:json-stringify": 1,
"src/main/cli/cli-installer.ts:read-file": 3,
"src/main/cli/linux-bare-orca-dispatcher.ts:read-file": 1,
"src/main/cli/linux-terminal-orca-cli-shim.ts:read-file": 1,
"src/main/cli/wsl-cli-registration-registry.ts:json-parse": 1,
"src/main/cli/wsl-cli-registration-registry.ts:json-stringify": 1,
"src/main/cli/wsl-cli-registration-registry.ts:read-file": 1,
"src/main/codex-accounts/codex-auth-identity.ts:json-parse": 1,
"src/main/codex-accounts/host-codex-managed-home-ownership.ts:read-file": 1,
"src/main/codex-accounts/legacy-managed-state-migration.ts:json-stringify": 2,
"src/main/codex-accounts/legacy-shared-auth-migration.ts:json-stringify": 1,
"src/main/codex-accounts/legacy-shared-auth-migration.ts:read-file": 1,
"src/main/codex-accounts/runtime-home-service.ts:json-parse": 2,
"src/main/codex-accounts/runtime-home-service.ts:json-stringify": 4,
"src/main/codex-accounts/runtime-home-service.ts:read-file": 20,
@@ -226,35 +161,30 @@
"src/main/codex-usage/store.ts:json-parse": 1,
"src/main/codex-usage/store.ts:json-stringify": 3,
"src/main/codex-usage/store.ts:read-file": 1,
"src/main/codex/codex-app-server-grant-bridge.ts:json-parse": 1,
"src/main/codex/codex-app-server-grant-bridge.ts:json-stringify": 1,
"src/main/codex/codex-app-server-grant-entry.ts:json-parse": 1,
"src/main/codex/codex-app-server-grant-entry.ts:json-stringify": 4,
"src/main/codex/codex-app-server-grant-json.ts:json-parse": 1,
"src/main/codex/codex-app-server-session.ts:json-parse": 1,
"src/main/codex/codex-app-server-session.ts:json-stringify": 1,
"src/main/codex/codex-home-paths.ts:json-parse": 1,
"src/main/codex/codex-home-paths.ts:json-stringify": 1,
"src/main/codex/codex-home-paths.ts:read-file": 2,
"src/main/codex/codex-hook-identity.ts:json-stringify": 1,
"src/main/codex/codex-real-home-hook-install.ts:read-file": 1,
"src/main/codex/codex-session-backfill-audit.ts:json-stringify": 1,
"src/main/codex/codex-session-backfill-marker.ts:json-parse": 1,
"src/main/codex/codex-session-backfill-marker.ts:json-stringify": 1,
"src/main/codex/codex-session-backfill-marker.ts:read-file": 1,
"src/main/codex/codex-session-bridge.ts:json-parse": 1,
"src/main/codex/codex-session-bridge.ts:read-file": 1,
"src/main/codex/codex-session-index-heal-state.ts:json-parse": 2,
"src/main/codex/codex-session-index-heal-jsonl.ts:json-parse": 1,
"src/main/codex/codex-session-index-heal-state.ts:json-parse": 1,
"src/main/codex/codex-session-index-heal-state.ts:json-stringify": 2,
"src/main/codex/codex-session-index-heal-state.ts:read-file": 2,
"src/main/codex/codex-trust-config-rollback.ts:read-file": 2,
"src/main/codex/codex-trust-grant-ledger.ts:json-parse": 1,
"src/main/codex/codex-trust-grant-ledger.ts:json-stringify": 1,
"src/main/codex/codex-trust-grant-ledger.ts:read-file": 1,
"src/main/codex/config-settings-baseline.ts:json-stringify": 1,
"src/main/codex/config-toml-trust.ts:json-stringify": 1,
"src/main/codex/config-toml-trust.ts:read-file": 1,
"src/main/codex/hook-service.ts:json-stringify": 1,
"src/main/codex/hook-service.ts:read-file": 3,
"src/main/codex/hook-trust-promotion.ts:json-parse": 1,
"src/main/codex/hook-trust-promotion.ts:json-stringify": 1,
"src/main/codex/hook-trust-promotion.ts:read-file": 1,
@@ -265,64 +195,43 @@
"src/main/computer/desktop-script-provider-test-harness.ts:json-parse": 1,
"src/main/computer/desktop-script-provider-test-harness.ts:json-stringify": 1,
"src/main/computer/macos-computer-use-permission-status.ts:json-parse": 1,
"src/main/computer/macos-computer-use-permission-status.ts:read-file": 1,
"src/main/computer/macos-native-provider-client.ts:json-parse": 1,
"src/main/computer/macos-native-provider-client.ts:json-stringify": 2,
"src/main/crash-reporting/crash-report-store.ts:json-parse": 1,
"src/main/crash-reporting/crash-report-store.ts:json-stringify": 1,
"src/main/crash-reporting/crash-report-store.ts:read-file": 1,
"src/main/daemon/client.ts:json-parse": 1,
"src/main/daemon/client.ts:read-file": 1,
"src/main/daemon/daemon-entry.ts:read-file": 1,
"src/main/daemon/daemon-file-log.ts:json-stringify": 1,
"src/main/daemon/daemon-health.ts:json-parse": 4,
"src/main/daemon/daemon-health.ts:read-file": 8,
"src/main/daemon/daemon-health.ts:json-parse": 2,
"src/main/daemon/daemon-health.ts:read-file": 4,
"src/main/daemon/daemon-host-relocation.ts:json-parse": 1,
"src/main/daemon/daemon-host-relocation.ts:json-stringify": 1,
"src/main/daemon/daemon-host-relocation.ts:read-file": 2,
"src/main/daemon/daemon-init.ts:read-file": 1,
"src/main/daemon/daemon-init.ts:unbounded-fanout": 1,
"src/main/daemon/daemon-pty-adapter.ts:json-stringify": 1,
"src/main/daemon/daemon-spawner.ts:json-parse": 1,
"src/main/daemon/daemon-spawner.ts:json-stringify": 1,
"src/main/daemon/daemon-spawner.ts:read-file": 1,
"src/main/daemon/daemon-stream-backlog-probe.ts:json-stringify": 2,
"src/main/daemon/degraded-daemon-fallback-shutdown.ts:unbounded-fanout": 1,
"src/main/daemon/history-manager.ts:json-parse": 4,
"src/main/daemon/history-manager.ts:json-stringify": 3,
"src/main/daemon/history-manager.ts:read-file": 4,
"src/main/daemon/ndjson.ts:json-parse": 1,
"src/main/daemon/ndjson.ts:json-stringify": 1,
"src/main/daemon/terminal-history-file-reader.ts:json-parse": 2,
"src/main/daemon/terminal-history-session-metadata.ts:json-stringify": 1,
"src/main/daemon/terminal-host-session-shutdown.ts:unbounded-fanout": 1,
"src/main/devin/hook-config-json.ts:read-file": 1,
"src/main/devin/hook-settings.ts:json-stringify": 1,
"src/main/diagnostics/main-thread-churn-probe.ts:json-stringify": 2,
"src/main/emulator/emulator-bridge.ts:unbounded-fanout": 1,
"src/main/emulator/emulator-probe.ts:json-stringify": 2,
"src/main/emulator/serve-sim-accessibility-tree.ts:json-parse": 1,
"src/main/emulator/serve-sim-accessibility-tree.ts:response-text": 1,
"src/main/emulator/serve-sim-detached-session.ts:json-parse": 1,
"src/main/emulator/serve-sim-detached-session.ts:read-file": 1,
"src/main/emulator/serve-sim-execution.ts:json-parse": 1,
"src/main/emulator/serve-sim-execution.ts:read-file": 1,
"src/main/emulator/simctl-simulator-devices.ts:json-parse": 1,
"src/main/filesystem-provider-bounded-text.ts:read-file": 1,
"src/main/ghostty/index.ts:json-stringify": 2,
"src/main/ghostty/index.ts:read-file": 1,
"src/main/ghostty/theme-resolution.ts:read-file": 1,
"src/main/git/huge-folder-ignore.ts:read-file": 1,
"src/main/git/repo.ts:read-file": 3,
"src/main/git/status.ts:read-file": 2,
"src/main/git/worktree-include-file.ts:read-file": 1,
"src/main/git/worktree.ts:read-file": 2,
"src/main/gitea/client.ts:response-text": 1,
"src/main/gitea/pull-request-creation.ts:json-stringify": 1,
"src/main/github/client.ts:json-parse": 37,
"src/main/github/client.ts:read-file": 2,
"src/main/github/conflict-summary-cache.ts:json-stringify": 1,
"src/main/github/issues.ts:json-parse": 7,
"src/main/github/local-git-config-signature.ts:read-file": 3,
"src/main/github/local-git-config-signature.ts:unbounded-fanout": 1,
"src/main/github/merged-pr-commit-membership.ts:json-parse": 1,
"src/main/github/project-view.ts:json-parse": 1,
"src/main/github/project-view/internals.ts:json-parse": 2,
@@ -335,83 +244,58 @@
"src/main/gitlab/issues.ts:json-parse": 5,
"src/main/gitlab/merge-request-creation-lookup.ts:json-parse": 2,
"src/main/gitlab/merge-request-creation-lookup.ts:json-stringify": 1,
"src/main/gitlab/merge-request-creation.ts:read-file": 2,
"src/main/gitlab/work-item-details.ts:json-parse": 8,
"src/main/hermes/hook-service.ts:json-stringify": 1,
"src/main/hermes/hook-service.ts:read-file": 4,
"src/main/hooks.ts:read-file": 4,
"src/main/ipc/ai-vault.ts:json-stringify": 1,
"src/main/integration-fanout.ts:unbounded-fanout": 1,
"src/main/ipc/ai-vault-host-scan-batches.ts:unbounded-fanout": 1,
"src/main/ipc/app.ts:json-parse": 1,
"src/main/ipc/crash-reporting.ts:json-stringify": 2,
"src/main/ipc/developer-permissions.ts:unbounded-fanout": 1,
"src/main/ipc/ephemeral-vm-runtime-handlers.ts:json-stringify": 1,
"src/main/ipc/feedback.ts:json-stringify": 1,
"src/main/ipc/filesystem-mutations.ts:read-file": 1,
"src/main/ipc/filesystem-watcher.ts:json-stringify": 1,
"src/main/ipc/filesystem-watcher.ts:unbounded-fanout": 4,
"src/main/ipc/filesystem.ts:read-file": 4,
"src/main/ipc/filesystem.ts:unbounded-fanout": 1,
"src/main/ipc/filesystem.ts:read-file": 1,
"src/main/ipc/notebook.ts:json-stringify": 2,
"src/main/ipc/notification-authorization-status.ts:json-parse": 1,
"src/main/ipc/notifications.ts:read-file": 1,
"src/main/ipc/pet.ts:json-parse": 1,
"src/main/ipc/pet.ts:read-file": 3,
"src/main/ipc/preflight.ts:unbounded-fanout": 1,
"src/main/ipc/pty-management.ts:unbounded-fanout": 2,
"src/main/ipc/pty.ts:unbounded-fanout": 1,
"src/main/ipc/remote-workspace.ts:unbounded-fanout": 1,
"src/main/ipc/repos.ts:read-file": 1,
"src/main/ipc/shell.ts:read-file": 1,
"src/main/ipc/ssh-worktree-create-root-registration.ts:unbounded-fanout": 1,
"src/main/ipc/ssh.ts:json-stringify": 1,
"src/main/ipc/ssh.ts:unbounded-fanout": 1,
"src/main/ipc/terminal-render-desync-evidence.ts:json-stringify": 1,
"src/main/ipc/watcher-removal-gate.ts:json-stringify": 1,
"src/main/ipc/workspace-cleanup-activity.ts:read-file": 1,
"src/main/ipc/workspace-ports.ts:json-stringify": 1,
"src/main/ipc/worktree-base-directory-poller.ts:unbounded-fanout": 3,
"src/main/ipc/worktree-base-directory-watch-targets.ts:read-file": 1,
"src/main/ipc/worktree-common-git-directory.ts:read-file": 1,
"src/main/ipc/worktree-git-common-polling.ts:unbounded-fanout": 3,
"src/main/ipc/worktree-head-identity-reader.ts:read-file": 1,
"src/main/ipc/worktree-remote.ts:read-file": 1,
"src/main/ipc/worktree-git-common-snapshot.ts:unbounded-fanout": 2,
"src/main/ipc/worktrees.ts:read-file": 8,
"src/main/jira/client.ts:json-parse": 1,
"src/main/jira/client.ts:json-stringify": 1,
"src/main/jira/client.ts:read-file": 2,
"src/main/jira/client.ts:response-text": 3,
"src/main/jira/issues.ts:json-stringify": 6,
"src/main/jira/issues.ts:unbounded-fanout": 2,
"src/main/keybindings/keybinding-file.ts:json-parse": 1,
"src/main/keybindings/keybinding-file.ts:json-stringify": 1,
"src/main/keybindings/keybinding-file.ts:read-file": 1,
"src/main/kimi/hook-service.ts:read-file": 2,
"src/main/linear/client.ts:json-parse": 2,
"src/main/linear/client.ts:json-stringify": 1,
"src/main/linear/client.ts:read-file": 3,
"src/main/linear/issue-context-client.ts:unbounded-fanout": 2,
"src/main/linear/issues.ts:unbounded-fanout": 2,
"src/main/linear/mcp-issue-list.ts:unbounded-fanout": 1,
"src/main/linear/projects.ts:unbounded-fanout": 1,
"src/main/linear/teams.ts:unbounded-fanout": 3,
"src/main/local-worktree-filesystem.ts:read-file": 1,
"src/main/minimax/minimax-cookie-store.ts:read-file": 1,
"src/main/native-chat/transcript-line-decoders-grok.ts:json-parse": 1,
"src/main/native-chat/transcript-record-blocks.ts:json-stringify": 1,
"src/main/observability/bundle.ts:json-parse": 1,
"src/main/observability/bundle.ts:json-stringify": 2,
"src/main/observability/bundle.ts:read-file": 1,
"src/main/observability/diagnostic-upload-http.ts:json-parse": 1,
"src/main/observability/diagnostic-upload-http.ts:json-stringify": 1,
"src/main/observability/local-file-sink.ts:json-stringify": 1,
"src/main/opencode-usage/scanner.ts:json-parse": 1,
"src/main/opencode-usage/scanner.ts:json-stringify": 1,
"src/main/opencode-usage/sqlite-usage-row-stream.ts:json-stringify": 1,
"src/main/opencode-usage/store.ts:json-parse": 1,
"src/main/opencode-usage/store.ts:json-stringify": 3,
"src/main/opencode-usage/store.ts:read-file": 1,
"src/main/opencode/hook-service.ts:json-parse": 1,
"src/main/opencode/hook-service.ts:json-stringify": 2,
"src/main/opencode/hook-service.ts:read-file": 2,
"src/main/opencode/config-overlay-manifest.ts:json-parse": 1,
"src/main/opencode/hook-service.ts:json-stringify": 1,
"src/main/orca-profiles/profile-cloud-client.ts:json-stringify": 1,
"src/main/orca-profiles/profile-cloud-client.ts:response-text": 1,
"src/main/orca-profiles/profile-cloud-org-members-client.ts:json-stringify": 1,
@@ -432,72 +316,50 @@
"src/main/persistence.ts:read-file": 3,
"src/main/pi/agent-status-extension-source.ts:json-stringify": 1,
"src/main/pi/agent-status-extension-source.ts:read-file": 2,
"src/main/pi/titlebar-extension-service.ts:read-file": 1,
"src/main/ports/local-workspace-port-scanner.ts:json-parse": 1,
"src/main/ports/local-workspace-port-scanner.ts:read-file": 2,
"src/main/project-groups/nested-repo-discovery.ts:read-file": 1,
"src/main/providers/pty-process-list-admission.ts:unbounded-fanout": 2,
"src/main/providers/ssh-filesystem-provider.ts:read-file": 3,
"src/main/providers/ssh-git-provider.ts:json-stringify": 1,
"src/main/providers/types.ts:read-file": 1,
"src/main/providers/windows-foreground-process-rows.ts:json-parse": 1,
"src/main/pty/shell-startup-env.ts:read-file": 1,
"src/main/pty/config-overlay-mirroring.ts:json-stringify": 1,
"src/main/quit-teardown-deadline.ts:unbounded-fanout": 1,
"src/main/rate-limits/claude-fetcher.ts:json-parse": 1,
"src/main/rate-limits/claude-fetcher.ts:read-file": 2,
"src/main/rate-limits/claude-fetcher.ts:response-text": 1,
"src/main/rate-limits/claude-oauth-usage-error.ts:response-text": 1,
"src/main/rate-limits/codex-fetcher.ts:json-parse": 2,
"src/main/rate-limits/codex-fetcher.ts:json-stringify": 3,
"src/main/rate-limits/codex-fetcher.ts:read-file": 1,
"src/main/rate-limits/codex-fetcher.ts:response-text": 3,
"src/main/rate-limits/gemini-cli-oauth-extractor.ts:json-parse": 1,
"src/main/rate-limits/gemini-cli-oauth-extractor.ts:read-file": 2,
"src/main/rate-limits/gemini-oauth-sources.ts:json-parse": 2,
"src/main/rate-limits/gemini-oauth-sources.ts:json-stringify": 2,
"src/main/rate-limits/gemini-oauth-sources.ts:read-file": 2,
"src/main/rate-limits/gemini-oauth-sources.ts:response-text": 2,
"src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts:json-stringify": 1,
"src/main/rate-limits/gemini-usage-fetcher.ts:json-stringify": 1,
"src/main/rate-limits/gemini-usage-fetcher.ts:response-text": 1,
"src/main/rate-limits/grok-auth.ts:json-parse": 1,
"src/main/rate-limits/grok-auth.ts:read-file": 1,
"src/main/rate-limits/grok-fetcher.ts:response-text": 1,
"src/main/rate-limits/kimi-fetcher.ts:json-parse": 1,
"src/main/rate-limits/kimi-fetcher.ts:read-file": 1,
"src/main/rate-limits/kimi-fetcher.ts:response-text": 1,
"src/main/rate-limits/minimax-fetcher.ts:response-text": 1,
"src/main/rate-limits/minimax-request-context.ts:unbounded-fanout": 1,
"src/main/rate-limits/opencode-go-usage-fetcher.ts:response-text": 2,
"src/main/remote-agent-trust-presets.ts:json-parse": 1,
"src/main/remote-agent-trust-presets.ts:json-stringify": 2,
"src/main/remote-agent-trust-presets.ts:read-file": 1,
"src/main/repo-icon-autodetect.ts:json-parse": 2,
"src/main/repo-icon-autodetect.ts:read-file": 6,
"src/main/repo-icon-autodetect.ts:read-file": 1,
"src/main/repo-icon-file-detection.ts:read-file": 2,
"src/main/runtime/agent-session-claim-identity.ts:read-file": 2,
"src/main/runtime/claude-agent-teams-shim-env.ts:read-file": 1,
"src/main/runtime/device-registry.ts:json-parse": 1,
"src/main/runtime/device-registry.ts:read-file": 1,
"src/main/runtime/e2ee-keypair.ts:json-parse": 1,
"src/main/runtime/e2ee-keypair.ts:read-file": 1,
"src/main/runtime/orca-runtime-files.ts:json-stringify": 1,
"src/main/runtime/orca-runtime-files.ts:read-file": 4,
"src/main/runtime/orca-runtime-files.ts:read-file": 2,
"src/main/runtime/orca-runtime-files.ts:unbounded-fanout": 1,
"src/main/runtime/orca-runtime.ts:json-stringify": 3,
"src/main/runtime/orca-runtime.ts:read-file": 8,
"src/main/runtime/orca-runtime.ts:unbounded-fanout": 2,
"src/main/runtime/orchestration/coordinator.ts:json-parse": 2,
"src/main/runtime/orchestration/db.ts:json-parse": 2,
"src/main/runtime/orca-runtime.ts:read-file": 1,
"src/main/runtime/orca-runtime.ts:unbounded-fanout": 1,
"src/main/runtime/orchestration/db.ts:json-stringify": 3,
"src/main/runtime/orchestration/lifecycle-reconciliation.ts:json-parse": 1,
"src/main/runtime/orchestration/lifecycle-reconciliation.ts:json-stringify": 1,
"src/main/runtime/relay/relay-control-client.ts:json-stringify": 4,
"src/main/runtime/orchestration/query-retention.ts:json-parse": 1,
"src/main/runtime/relay/relay-control-protocol.ts:json-parse": 1,
"src/main/runtime/relay/relay-http-client.ts:json-stringify": 2,
"src/main/runtime/relay/relay-http-client.ts:response-text": 2,
"src/main/runtime/relay/relay-revoke-outbox.ts:json-parse": 1,
"src/main/runtime/relay/relay-revoke-outbox.ts:read-file": 1,
"src/main/runtime/rpc/dispatcher.ts:json-stringify": 6,
"src/main/runtime/rpc/e2ee-channel.ts:json-stringify": 4,
"src/main/runtime/rpc/methods/orchestration-gates.ts:json-parse": 1,
"src/main/runtime/rpc/methods/orchestration.ts:json-parse": 1,
@@ -505,51 +367,33 @@
"src/main/runtime/rpc/relay-transport.ts:json-stringify": 1,
"src/main/runtime/rpc/ws-fallback-port-store.ts:json-parse": 1,
"src/main/runtime/rpc/ws-fallback-port-store.ts:json-stringify": 1,
"src/main/runtime/rpc/ws-fallback-port-store.ts:read-file": 1,
"src/main/runtime/runtime-metadata.ts:json-parse": 1,
"src/main/runtime/runtime-metadata.ts:read-file": 1,
"src/main/runtime/runtime-rpc.ts:json-parse": 4,
"src/main/runtime/runtime-rpc.ts:json-parse": 2,
"src/main/runtime/runtime-rpc.ts:json-stringify": 11,
"src/main/runtime/runtime-rpc.ts:unbounded-fanout": 2,
"src/main/runtime/tls-certificate.ts:read-file": 4,
"src/main/runtime/windows-mobile-firewall.ts:json-parse": 2,
"src/main/runtime/worktree-teardown.ts:unbounded-fanout": 1,
"src/main/serve-update-handoff.ts:json-parse": 2,
"src/main/serve-update-handoff.ts:json-stringify": 1,
"src/main/serve-update-handoff.ts:read-file": 2,
"src/main/server/serve-readiness.ts:json-stringify": 2,
"src/main/skills/claude-plugin-skill-sources.ts:json-parse": 1,
"src/main/skills/discovery.ts:unbounded-fanout": 2,
"src/main/skills/skill-bundle-artifacts.ts:read-file": 3,
"src/main/skills/skill-freshness-inventory.ts:unbounded-fanout": 1,
"src/main/skills/skill-package-identity.ts:json-stringify": 1,
"src/main/source-control/hosted-review-api-request.ts:response-text": 2,
"src/main/source-control/pull-request-template.ts:read-file": 2,
"src/main/source-control/pull-request-template.ts:read-file": 1,
"src/main/speech/openai-api-key-store.ts:json-parse": 1,
"src/main/speech/openai-api-key-store.ts:read-file": 2,
"src/main/speech/openai-transcription-client.ts:response-text": 1,
"src/main/speech/stt-worker.ts:json-parse": 3,
"src/main/ssh/relay-protocol.ts:json-parse": 1,
"src/main/ssh/relay-protocol.ts:json-stringify": 1,
"src/main/ssh/ssh-agent-identity-filter.ts:read-file": 1,
"src/main/ssh/ssh-auth-resolution.ts:read-file": 2,
"src/main/ssh/ssh-config-include-expander.ts:read-file": 1,
"src/main/ssh/sftp-namespace-resolution.ts:json-stringify": 2,
"src/main/ssh/ssh-control-socket.ts:json-stringify": 1,
"src/main/ssh/ssh-git-response-stream-reader.ts:json-parse": 1,
"src/main/ssh/ssh-port-forward.ts:unbounded-fanout": 1,
"src/main/ssh/ssh-git-response-stream-assembler.ts:json-parse": 1,
"src/main/ssh/ssh-relay-deploy.ts:json-stringify": 2,
"src/main/ssh/ssh-relay-install-namespace.ts:json-stringify": 1,
"src/main/ssh/ssh-relay-session.ts:unbounded-fanout": 1,
"src/main/ssh/ssh-relay-versioned-install.ts:read-file": 1,
"src/main/ssh/ssh-remote-cli-format.ts:json-stringify": 1,
"src/main/ssh/ssh-remote-commands.ts:read-file": 1,
"src/main/ssh/ssh-remote-orca-cli.ts:json-stringify": 3,
"src/main/ssh/ssh-remote-orchestration-send.ts:json-stringify": 1,
"src/main/ssh/ssh-remote-platform.ts:json-stringify": 2,
"src/main/ssh/system-ssh-file-transfer.ts:json-stringify": 1,
"src/main/ssh/system-ssh-file-transfer.ts:read-file": 1,
"src/main/startup/configure-process.ts:json-parse": 1,
"src/main/startup/configure-process.ts:read-file": 1,
"src/main/startup/ensure-virtual-display.ts:read-file": 1,
"src/main/startup/gpu-fallback-marker.ts:json-parse": 1,
"src/main/startup/gpu-fallback-marker.ts:json-stringify": 1,
"src/main/startup/gpu-fallback-marker.ts:read-file": 1,
@@ -557,53 +401,25 @@
"src/main/startup/windows-user-data-acl.ts:json-parse": 1,
"src/main/startup/windows-user-data-acl.ts:json-stringify": 1,
"src/main/startup/windows-user-data-acl.ts:read-file": 1,
"src/main/stats/collector.ts:json-parse": 1,
"src/main/stats/collector.ts:json-stringify": 1,
"src/main/stats/collector.ts:read-file": 1,
"src/main/stats/stats-retention.ts:json-parse": 1,
"src/main/system-fonts.ts:json-parse": 1,
"src/main/telemetry/consent.ts:json-stringify": 1,
"src/main/terminal-history.ts:json-parse": 1,
"src/main/terminal-history-gc.ts:json-parse": 1,
"src/main/terminal-history.ts:json-stringify": 1,
"src/main/terminal-history.ts:read-file": 1,
"src/main/updater-changelog.ts:response-text": 1,
"src/main/text-generation/commit-message-text-generation.ts:json-stringify": 1,
"src/main/updater-lifecycle-diagnostics.ts:json-stringify": 1,
"src/main/updater-nudge.ts:response-text": 1,
"src/main/updater-prerelease-feed.ts:response-text": 2,
"src/main/updater-prerelease-feed.ts:unbounded-fanout": 2,
"src/main/warp-themes/index.ts:read-file": 1,
"src/main/window/clipboard-remote-file-copy.ts:unbounded-fanout": 1,
"src/main/workspace-space-analysis.ts:unbounded-fanout": 4,
"src/main/worktree-removal-safety.ts:read-file": 1,
"src/relay/agent-exec-handler.ts:json-stringify": 1,
"src/relay/external-automations-handler.ts:json-parse": 1,
"src/relay/external-automations-handler.ts:read-file": 3,
"src/relay/external-automations-handler.ts:unbounded-fanout": 1,
"src/relay/fs-handler-file-read.ts:read-file": 2,
"src/relay/fs-handler.ts:json-stringify": 1,
"src/relay/fs-handler.ts:read-file": 2,
"src/relay/fs-handler.ts:unbounded-fanout": 1,
"src/relay/fs-stream-registry.ts:unbounded-fanout": 1,
"src/relay/git-handler-status-ops.ts:read-file": 1,
"src/relay/git-handler.ts:json-stringify": 1,
"src/relay/git-working-file-read.ts:read-file": 1,
"src/relay/plugin-overlay.ts:read-file": 1,
"src/relay/port-scan-handler.ts:read-file": 2,
"src/relay/preflight-handler.ts:unbounded-fanout": 1,
"src/relay/protocol.ts:json-parse": 2,
"src/relay/protocol.ts:json-stringify": 2,
"src/relay/pty-handler.ts:json-parse": 1,
"src/relay/pty-handler.ts:json-stringify": 1,
"src/relay/mobile-file-directory-reader.ts:unbounded-fanout": 1,
"src/relay/protocol.ts:json-stringify": 1,
"src/relay/pty-handler.ts:unbounded-fanout": 1,
"src/relay/pty-shell-launch.ts:read-file": 1,
"src/relay/pty-shell-utils.ts:read-file": 1,
"src/relay/relay-handshake.ts:read-file": 1,
"src/relay/relay-watcher-removal-fence.ts:unbounded-fanout": 2,
"src/relay/pty-persistence-envelope.ts:json-parse": 1,
"src/relay/windows-port-scan.ts:json-parse": 1,
"src/relay/workspace-session-handler.ts:json-parse": 1,
"src/relay/workspace-session-handler.ts:json-stringify": 1,
"src/relay/workspace-session-handler.ts:read-file": 1,
"src/relay/workspace-space-scan.ts:unbounded-fanout": 3,
"src/relay/wsl-hook-fs-bridge.ts:read-file": 1,
"src/renderer/src/App.tsx:unbounded-fanout": 1,
"src/renderer/src/components/GitHubItemDialog.tsx:json-stringify": 2,
"src/renderer/src/components/PullRequestPage.tsx:json-stringify": 2,
@@ -618,12 +434,11 @@
"src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx:json-stringify": 1,
"src/renderer/src/components/editor/CombinedDiffViewer.tsx:json-stringify": 2,
"src/renderer/src/components/editor/IpynbViewer.tsx:json-stringify": 2,
"src/renderer/src/components/editor/MonacoEditor.tsx:json-stringify": 1,
"src/renderer/src/components/editor/editor-autosave-controller.ts:unbounded-fanout": 3,
"src/renderer/src/components/editor/ipynb-parse.ts:json-parse": 2,
"src/renderer/src/components/editor/ipynb-parse.ts:json-parse": 1,
"src/renderer/src/components/editor/ipynb-parse.ts:json-stringify": 1,
"src/renderer/src/components/editor/markdown-document-list-request.ts:json-stringify": 1,
"src/renderer/src/components/editor/markdown-export-extract.ts:response-text": 2,
"src/renderer/src/components/editor/markdown-export-extract.ts:unbounded-fanout": 1,
"src/renderer/src/components/editor/raw-markdown-html.ts:json-stringify": 1,
"src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts:json-parse": 1,
"src/renderer/src/components/editor/useEditorPanelContentState.ts:json-stringify": 1,
@@ -695,9 +510,7 @@
"src/renderer/src/components/task-page-cache-selectors.ts:json-stringify": 2,
"src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts:unbounded-fanout": 1,
"src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts:json-stringify": 1,
"src/renderer/src/hooks/useIpcEvents.ts:unbounded-fanout": 2,
"src/renderer/src/hooks/useIssueMetadata.ts:unbounded-fanout": 1,
"src/renderer/src/lib/agent-hibernation-coordinator.ts:unbounded-fanout": 1,
"src/renderer/src/lib/ai-vault-session-drag.ts:json-parse": 1,
"src/renderer/src/lib/ai-vault-session-drag.ts:json-stringify": 1,
"src/renderer/src/lib/app-font-family.ts:json-stringify": 1,
@@ -705,19 +518,15 @@
"src/renderer/src/lib/execute-open-editor-path-move.ts:unbounded-fanout": 1,
"src/renderer/src/lib/feature-education-telemetry.ts:json-parse": 2,
"src/renderer/src/lib/feature-education-telemetry.ts:json-stringify": 2,
"src/renderer/src/lib/react-error-boundary-reporting.ts:json-stringify": 1,
"src/renderer/src/lib/repo-slug-index.ts:unbounded-fanout": 1,
"src/renderer/src/lib/workspace-file-drag.ts:json-parse": 1,
"src/renderer/src/lib/workspace-file-drag.ts:json-stringify": 1,
"src/renderer/src/lib/workspace-port-actions.ts:json-stringify": 1,
"src/renderer/src/lib/workspace-session-host-persistence.ts:unbounded-fanout": 1,
"src/renderer/src/lib/worktree-operation-generation.ts:json-stringify": 1,
"src/renderer/src/lib/worktree-operation-route.ts:json-stringify": 2,
"src/renderer/src/lib/worktree-runtime-owner-index.ts:json-stringify": 2,
"src/renderer/src/runtime/runtime-file-client.ts:read-file": 3,
"src/renderer/src/runtime/sync-runtime-graph.ts:json-stringify": 7,
"src/renderer/src/runtime/web-session-intent-owner.ts:json-stringify": 1,
"src/renderer/src/runtime/web-session-tabs-sync.ts:unbounded-fanout": 2,
"src/renderer/src/store/slices/github.ts:unbounded-fanout": 3,
"src/renderer/src/store/slices/linear.ts:json-stringify": 1,
"src/renderer/src/store/slices/pull-request-generation.ts:json-stringify": 1,
@@ -729,15 +538,13 @@
"src/renderer/src/store/slices/terminals.ts:unbounded-fanout": 1,
"src/renderer/src/store/slices/workspace-cleanup.ts:json-stringify": 3,
"src/renderer/src/store/slices/workspace-cleanup.ts:unbounded-fanout": 2,
"src/renderer/src/web/web-local-storage-json.ts:json-parse": 1,
"src/renderer/src/web/web-pairing.ts:json-parse": 1,
"src/renderer/src/web/web-preload-api.ts:json-parse": 3,
"src/renderer/src/web/web-preload-api.ts:json-stringify": 2,
"src/renderer/src/web/web-preload-api.ts:unbounded-fanout": 1,
"src/renderer/src/web/web-runtime-client.ts:json-parse": 3,
"src/renderer/src/web/web-runtime-client.ts:json-stringify": 3,
"src/renderer/src/web/web-runtime-client.ts:json-parse": 1,
"src/renderer/src/web/web-runtime-client.ts:json-stringify": 1,
"src/renderer/src/web/web-runtime-client.ts:response-text": 1,
"src/renderer/src/web/web-runtime-environment.ts:json-parse": 1,
"src/renderer/src/web/web-runtime-environment.ts:json-stringify": 1,
"src/renderer/src/web/web-runtime-inbound-json.ts:json-parse": 1,
"src/renderer/src/web/web-runtime-outbound-json.ts:json-stringify": 1,
"src/shared/agent-hook-listener.ts:json-parse": 1,
"src/shared/agent-hook-listener.ts:json-stringify": 16,
"src/shared/agent-question-answered-intent.ts:json-parse": 1,
@@ -764,6 +571,7 @@
"src/shared/git-uncommitted-line-stats.ts:unbounded-fanout": 1,
"src/shared/in-flight-promise-dedupe.ts:json-stringify": 1,
"src/shared/linear-issue-attribute-filter.ts:json-stringify": 1,
"src/shared/linux-proc-port-scan-limits.ts:read-file": 2,
"src/shared/map-with-concurrency.ts:unbounded-fanout": 2,
"src/shared/mcp-config.ts:json-parse": 1,
"src/shared/mobile-relay-pairing-fixtures.ts:json-stringify": 1,
@@ -771,9 +579,11 @@
"src/shared/native-chat-tool-summary.ts:json-stringify": 2,
"src/shared/pairing.ts:json-parse": 1,
"src/shared/pairing.ts:json-stringify": 1,
"src/shared/persisted-state-file-bounds.ts:json-parse": 1,
"src/shared/pr-checks-fix-prompt.ts:json-stringify": 2,
"src/shared/pull-request-generation.ts:json-parse": 1,
"src/shared/quick-open-readdir-walk.ts:unbounded-fanout": 1,
"src/shared/relay-json-admission.ts:json-parse": 1,
"src/shared/remote-runtime-client.ts:json-stringify": 2,
"src/shared/remote-runtime-request-frames.ts:json-parse": 1,
"src/shared/remote-runtime-request-websocket.ts:json-stringify": 1,
@@ -0,0 +1,150 @@
// Guard: a branch must not drop work that is already reachable from its own merge base.
//
// Building a branch by checking out whole files from an older stack (instead of merging) silently
// reverts shipped fixes. Typecheck and tests cannot see it — a reverted feature still compiles, and
// its old tests still pass — so this compares, for every file both sides touched since the merge
// base, whether lines the base branch added are missing from the topic branch.
//
// Run: node config/scripts/check-no-merge-base-revert.mjs [--base origin/main] [--head HEAD] [--json]
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const IGNORED_LINE = /^\s*(\/\/|\/\*|\*|#)?\s*$/
function git(root, args) {
return execFileSync('git', args, { cwd: root, maxBuffer: 1e9, encoding: 'utf8' })
}
function fileAt(root, ref, file) {
try {
return execFileSync('git', ['show', `${ref}:${file}`], {
cwd: root,
maxBuffer: 1e9,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
} catch {
return null
}
}
// A line "survives" if it appears anywhere in the head version. Position-independent on purpose:
// the point is whether the work is still present, not whether it moved.
export function findDroppedLines(baseText, targetText, headText) {
if (baseText === null || targetText === null) {
return []
}
const baseLines = new Set(baseText.split('\n').map((l) => l.trim()))
const headLines = new Set((headText ?? '').split('\n').map((l) => l.trim()))
const dropped = []
for (const raw of targetText.split('\n')) {
const line = raw.trim()
if (IGNORED_LINE.test(line) || line.length < 8) {
continue
}
// Only consider lines the base branch ADDED after the merge base.
if (baseLines.has(line) || headLines.has(line)) {
continue
}
dropped.push(line)
}
return dropped
}
export function analyze(root, baseRef, headRef) {
const mergeBase = git(root, ['merge-base', baseRef, headRef]).trim()
const changedByBase = new Set(
git(root, ['diff', '--name-only', mergeBase, baseRef]).split('\n').filter(Boolean)
)
const changedByHead = new Set(
git(root, ['diff', '--name-only', mergeBase, headRef]).split('\n').filter(Boolean)
)
const overlap = [...changedByBase].filter((f) => changedByHead.has(f)).sort()
// Whole files the base branch ADDED that the topic branch lacks. Line-level comparison cannot see
// these — the file simply is not there — yet dropping one silently removes a shipped feature.
const missingFiles = []
for (const file of git(root, ['diff', '--name-status', mergeBase, baseRef]).split('\n')) {
const [status, ...rest] = file.split('\t')
const target = rest.join('\t')
if (status !== 'A' || !target) {
continue
}
if (fileAt(root, headRef, target) === null) {
missingFiles.push(target)
}
}
const reverts = []
for (const file of overlap) {
const baseText = fileAt(root, mergeBase, file)
const targetText = fileAt(root, baseRef, file)
const headText = fileAt(root, headRef, file)
if (targetText === null) {
continue // deleted on the base branch; not a revert of added work
}
const dropped = findDroppedLines(baseText, targetText, headText)
if (dropped.length > 0) {
reverts.push({ file, droppedLines: dropped.length, sample: dropped.slice(0, 3) })
}
}
reverts.sort((a, b) => b.droppedLines - a.droppedLines)
return { mergeBase, overlapCount: overlap.length, reverts, missingFiles }
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
const root = path.resolve(import.meta.dirname, '../..')
const argv = process.argv.slice(2)
const readArg = (name, fallback) => {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback
}
const baseRef = readArg('--base', 'origin/main')
const headRef = readArg('--head', 'HEAD')
const asJson = argv.includes('--json')
const { mergeBase, overlapCount, reverts, missingFiles } = analyze(root, baseRef, headRef)
if (asJson) {
console.log(JSON.stringify({ mergeBase, overlapCount, reverts, missingFiles }, null, 2))
}
if (missingFiles.length > 0) {
console.error(
`This branch is MISSING ${missingFiles.length} file(s) that ${baseRef} added since the merge base:\n`
)
for (const f of missingFiles.slice(0, 25)) {
console.error(` ${f}`)
}
if (missingFiles.length > 25) {
console.error(` ... and ${missingFiles.length - 25} more`)
}
console.error('')
}
if (reverts.length > 0 || missingFiles.length > 0) {
if (reverts.length > 0) {
console.error(
`This branch drops work already present on ${baseRef} (merge base ${mergeBase.slice(0, 10)}).\n` +
`${reverts.length} of ${overlapCount} overlapping files lose lines ${baseRef} added:\n`
)
for (const r of reverts.slice(0, 25)) {
console.error(` ${r.file} (-${r.droppedLines} lines)`)
for (const s of r.sample) {
console.error(` ${s.slice(0, 100)}`)
}
}
if (reverts.length > 25) {
console.error(` ... and ${reverts.length - 25} more`)
}
}
console.error(
`\nRebuild by MERGING onto ${baseRef} rather than overwriting.` +
`\nTypecheck and tests cannot catch this: a reverted feature still compiles and its old tests still pass.`
)
process.exit(1)
}
console.log(
`no-merge-base-revert: clean (${overlapCount} overlapping files, 0 reverted, 0 missing)`
)
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { findDroppedLines } from './check-no-merge-base-revert.mjs'
const base = ['const a = someValueHere', 'const b = anotherValueHere'].join('\n')
const withMainFix = [base, 'const shipped = mainAddedThisFix()'].join('\n')
describe('check-no-merge-base-revert', () => {
it('flags a line the base branch added that the head branch lacks', () => {
const dropped = findDroppedLines(base, withMainFix, base)
expect(dropped).toEqual(['const shipped = mainAddedThisFix()'])
})
it('accepts a head branch that kept the added line', () => {
const head = [withMainFix, 'const bound = MAX_RETAINED_BYTES'].join('\n')
expect(findDroppedLines(base, withMainFix, head)).toEqual([])
})
it('is position-independent — moved code still counts as present', () => {
const head = ['const shipped = mainAddedThisFix()', base].join('\n')
expect(findDroppedLines(base, withMainFix, head)).toEqual([])
})
it('ignores whitespace and trivially short lines', () => {
const noisy = [base, '', ' ', '//', 'x++'].join('\n')
expect(findDroppedLines(base, noisy, base)).toEqual([])
})
it('flags a dropped explanatory comment', () => {
// Losing a `// Why:` that documents a non-obvious invariant is real lost work — several of the
// reverts this guard was written for were exactly that.
const withComment = [base, '// Why: reused ids restart aligned at zero'].join('\n')
expect(findDroppedLines(base, withComment, base)).toEqual([
'// Why: reused ids restart aligned at zero'
])
})
it('reports nothing when the base branch added nothing', () => {
expect(findDroppedLines(base, base, base)).toEqual([])
})
it('treats a file absent on either side as no-revert rather than throwing', () => {
expect(findDroppedLines(null, withMainFix, base)).toEqual([])
expect(findDroppedLines(base, null, base)).toEqual([])
})
})
+8
View File
@@ -4,6 +4,9 @@
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/agent-hook-file-comparison.ts",
"../src/main/agent-hooks/agent-hook-file-limits.ts",
"../src/main/agent-hooks/agent-hook-sftp-text-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
@@ -20,6 +23,7 @@
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-grant-bridge.ts",
"../src/main/codex/codex-app-server-grant-envelope.ts",
"../src/main/codex/codex-app-server-grant-json.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
@@ -27,6 +31,7 @@
"../src/main/codex/codex-config-settings-removal.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-host-retry-deadlines.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
@@ -38,6 +43,7 @@
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
@@ -52,12 +58,14 @@
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/codex-accounts/wsl-codex-command.ts",
"../src/main/codex-cli/command.ts",
"../src/main/codex-cli/nvm-version-directory-discovery.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/generated-node-bounded-file-reader.ts",
"../src/main/grok/hook-service.ts",
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
+173 -152
View File
@@ -86,6 +86,10 @@ import {
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
serializeLastVisitedWorktreeRecord
} from '../../../../src/worktree/last-visited-worktree-repo'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
@@ -214,12 +218,6 @@ import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-m
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
import type {
SessionTabsApplyOutcome,
SessionTabsStreamSource
} from '../../../../src/session/mobile-session-tabs-stream-health'
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
@@ -233,6 +231,8 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle'
import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
@@ -873,7 +873,6 @@ export default function SessionScreen() {
const sessionTabsRef = useRef<MobileSessionTab[]>([])
// Why: track the last applied (epoch, version) so a late older snapshot can't overwrite a newer one and resurrect closed tabs (session-tab-snapshot-gate).
const appliedSnapshotMarkerRef = useRef<AppliedSnapshotMarker>({ epoch: null, version: -1 })
const appliedSessionTabsRevisionRef = useRef(0)
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
@@ -904,7 +903,9 @@ export default function SessionScreen() {
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
const diffCommentsRef = useRef<DiffComment[]>([])
const [diffCommentBusy, setDiffCommentBusy] = useState(false)
@@ -1697,13 +1698,12 @@ export default function SessionScreen() {
)
const applySessionTabs = useCallback(
(result: SessionTabsResult): SessionTabsApplyOutcome<MobileSessionTab> => {
(result: SessionTabsResult) => {
const diagnostics = terminalDiagnosticsRef.current
// Reject stale snapshots; suppress just-closed tabs until the publisher confirms absence — see session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
return { accepted: false }
return
}
const applicationRevision = ++appliedSessionTabsRevisionRef.current
let nextTabs = applyClosedTabTombstones(
result.tabs,
closedTabTombstonesRef.current,
@@ -1729,6 +1729,8 @@ export default function SessionScreen() {
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs)
fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs)
sessionTabsRef.current = nextTabs
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
@@ -1748,11 +1750,6 @@ export default function SessionScreen() {
terminalTabs.length
)
setTerminalsLoaded(true)
const outcome = {
accepted: true as const,
effectiveTabs: nextTabs,
applicationRevision
}
const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
@@ -1805,7 +1802,7 @@ export default function SessionScreen() {
activeSessionTabTypeRef.current = 'terminal'
setActiveHandle(pendingActiveTerminalHandle)
subscribeToTerminal(pendingActiveTerminalHandle)
return outcome
return
} else {
pendingActiveTerminalHandleRef.current = null
}
@@ -1823,7 +1820,7 @@ export default function SessionScreen() {
}
activeHandleRef.current = null
setActiveHandle(null)
return outcome
return
}
const previous = activeHandleRef.current
if (previous && previous !== active.terminal) {
@@ -1842,7 +1839,6 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
}
return outcome
},
[defaultTerminalHandlesToLiveInput, subscribeToTerminal, unsubscribeTerminal]
)
@@ -1852,8 +1848,7 @@ export default function SessionScreen() {
if (!client) {
return
}
setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => {
const response = await client.sendRequest('markdown.readTab', {
worktree: `id:${worktreeId}`,
tabId: tab.id
@@ -1866,19 +1861,16 @@ export default function SessionScreen() {
editable?: boolean
readOnlyReason?: string
}
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
})
)
return
return {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
}
}
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
throw new Error((response as RpcFailure).error.message)
@@ -1896,24 +1888,12 @@ export default function SessionScreen() {
truncated: boolean
byteLength: number
}
setMarkdownDocs((prev) =>
new Map(prev).set(
tab.id,
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
)
)
} catch {
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
})
)
}
return buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
})
},
[client, worktreeId]
)
@@ -1923,31 +1903,13 @@ export default function SessionScreen() {
if (!client) {
return
}
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
const doc = await resolveMobileFileTabDoc(client, {
await fileDocLifecycleRef.current.load(tab, setFileDocs, () =>
resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
} catch (err) {
const message = err instanceof Error ? err.message : ''
const previewMessage =
message === 'binary_file'
? 'Binary preview unavailable'
: message === 'file_too_large'
? 'File too large for mobile preview'
: tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: previewMessage
})
)
}
)
},
[client, worktreeId]
)
@@ -2262,75 +2224,56 @@ export default function SessionScreen() {
})
} finally {
markdownSaveInFlightRef.current.delete(tab.id)
if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) {
markdownSaveSeqRef.current.delete(tab.id)
}
}
},
[client, markdownDocs, showToast, worktreeId]
)
const consumeAcceptedSessionTabs = useCallback(
(
_result: SessionTabsResult,
effectiveTabs: readonly MobileSessionTab[],
source: SessionTabsStreamSource
): void => {
runAcceptedMobileSessionTabsEffects<MobileSessionTab>({
effectiveTabs,
source,
getPendingBrowserPageId: () => pendingBrowserFocusPageIdRef.current,
clearPendingBrowserPageId: (pageId) => {
if (pendingBrowserFocusPageIdRef.current === pageId) {
pendingBrowserFocusPageIdRef.current = null
}
},
activateBrowserTab: (tab) => switchSessionTabRef.current?.(tab),
markActiveMarkdownStale: (tabId) => {
setMarkdownDocs((prev) => {
const current = prev.get(tabId)
if (current?.status !== 'ready' || current.isDirty) {
return prev
}
return new Map(prev).set(tabId, { ...current, stale: true })
})
}
})
},
[]
)
const hasSessionTabsRecoveryNeed = useCallback(
() => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null,
[]
)
const getSessionTabsApplicationRevision = useCallback(
() => appliedSessionTabsRevisionRef.current,
[]
)
const reportSessionTabsFetchStarted = useCallback(() => {
const fetchSessionTabsInFlightRef = useRef(false)
const fetchSessionTabs = useCallback(async () => {
if (!client) {
terminalDiagnosticsRef.current.tabsFetchSkipped('no-client')
return
}
if (fetchSessionTabsInFlightRef.current) {
terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight')
return
}
fetchSessionTabsInFlightRef.current = true
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
}, [worktreeId])
const reportSessionTabsFetchSucceeded = useCallback((result: SessionTabsResult) => {
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
}, [])
const reportSessionTabsFetchFailed = useCallback((code: string) => {
terminalDiagnosticsRef.current.tabsFetchFailed(code)
}, [])
const reportSessionTabsFetchErrored = useCallback((error: unknown) => {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
}, [])
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
connState,
worktreeId,
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
getApplicationRevision: getSessionTabsApplicationRevision,
onFetchStarted: reportSessionTabsFetchStarted,
onFetchSucceeded: reportSessionTabsFetchSucceeded,
onFetchFailed: reportSessionTabsFetchFailed,
onFetchErrored: reportSessionTabsFetchErrored
})
try {
const response = await client.sendRequest('session.tabs.list', {
worktree: `id:${worktreeId}`
})
if (!response.ok) {
terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code)
return
}
const result = (response as RpcSuccess).result as SessionTabsResult
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
applySessionTabs(result)
// Focus a just-opened browser tab when it appears, via the normal activate path so it sticks yet stays switchable.
const pendingPageId = pendingBrowserFocusPageIdRef.current
if (pendingPageId) {
const browserTab = result.tabs.find(
(tab) => tab.type === 'browser' && tab.browserPageId === pendingPageId
)
if (browserTab) {
pendingBrowserFocusPageIdRef.current = null
switchSessionTabRef.current?.(browserTab)
}
}
} catch (error) {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
// Keep the last tab snapshot visible during reconnect/backoff.
} finally {
fetchSessionTabsInFlightRef.current = false
}
}, [applySessionTabs, client, worktreeId])
useEffect(() => {
if (connState === 'connected') {
@@ -2556,10 +2499,10 @@ export default function SessionScreen() {
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
'orca:last-visited-worktree',
JSON.stringify({ hostId, worktreeId })
)
const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId })
if (serialized) {
void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized)
}
}
}, [hostId, worktreeId])
@@ -2593,6 +2536,10 @@ export default function SessionScreen() {
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
markdownDocLifecycleRef.current.reset()
fileDocLifecycleRef.current.reset()
markdownSaveSeqRef.current.clear()
markdownSaveInFlightRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2612,6 +2559,10 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
markdownDocLifecycleRef.current.reset()
fileDocLifecycleRef.current.reset()
markdownSaveSeqRef.current.clear()
markdownSaveInFlightRef.current.clear()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
@@ -2661,7 +2612,7 @@ export default function SessionScreen() {
if (disposed) {
return
}
await ensureSessionTabs().catch(() => null)
await fetchSessionTabs().catch(() => null)
if (disposed) {
return
}
@@ -2704,13 +2655,72 @@ export default function SessionScreen() {
client,
connState,
created,
fetchSessionTabs,
fetchTerminals,
ensureSessionTabs,
isFloatingWorkspaceRoute,
showToast,
worktreeId
])
useEffect(() => {
if (!client || connState !== 'connected') {
return
}
const unsubscribe = client.subscribe(
'session.tabs.subscribe',
{ worktree: `id:${worktreeId}` },
(payload) => {
const event = payload as { type?: string } & SessionTabsResult
if (event.type === 'snapshot' || event.type === 'updated') {
applySessionTabs(event)
const activeMarkdown = event.tabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'markdown' }> =>
tab.type === 'markdown' && tab.isActive
)
if (activeMarkdown) {
setMarkdownDocs((prev) => {
const current = prev.get(activeMarkdown.id)
if (current?.status === 'ready' && activeMarkdown.isDirty && !current.isDirty) {
const next = new Map(prev)
next.set(activeMarkdown.id, { ...current, stale: true })
return next
}
return prev
})
}
}
}
)
return () => unsubscribe()
}, [applySessionTabs, client, connState, worktreeId])
useFocusEffect(
useCallback(() => {
if (connState !== 'connected') {
return
}
const refreshOnForeground = () => {
if (AppState.currentState !== 'active') {
return
}
void fetchSessionTabs()
void fetchTerminals()
}
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refreshOnForeground()
}
})
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded.
const interval = setInterval(refreshOnForeground, 2000)
refreshOnForeground()
return () => {
clearInterval(interval)
appStateSubscription.remove()
}
}, [connState, fetchSessionTabs, fetchTerminals])
)
// Why: pick up Settings → Terminal text size on return; panes stay mounted and update in place.
useFocusEffect(
useCallback(() => {
@@ -3631,7 +3641,7 @@ export default function SessionScreen() {
nativeChatInputLeaseReady,
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
nativeChatBaseSend: nativeChatController.handleNativeChatSend,
showToast,
onSuccess: triggerSelection,
onError: triggerError
@@ -3962,8 +3972,8 @@ export default function SessionScreen() {
pendingBrowserFocusPageIdRef.current = created.browserPageId
}
void fetchSessionTabs()
scheduleDelayedAction(() => void fetchPendingBrowserSessionTabs(), 400)
scheduleDelayedAction(() => void fetchPendingBrowserSessionTabs(), 1200)
scheduleDelayedAction(() => void fetchSessionTabs(), 400)
scheduleDelayedAction(() => void fetchSessionTabs(), 1200)
return true
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create browser'
@@ -4079,9 +4089,6 @@ export default function SessionScreen() {
reason: 'user'
})
if (response.ok) {
if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) {
pendingBrowserFocusPageIdRef.current = null
}
if (tab.type === 'terminal' && typeof tab.terminal === 'string') {
const terminalHandle = tab.terminal
unsubscribeTerminal(terminalHandle)
@@ -4089,7 +4096,23 @@ export default function SessionScreen() {
initializedHandlesRef.current.delete(terminalHandle)
clearTerminalLiveInputDefault(terminalHandle)
}
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
if (tab.type === 'file') {
fileDocLifecycleRef.current.close(tab.id, setFileDocs)
}
if (tab.type === 'markdown') {
markdownDocLifecycleRef.current.close(tab.id, (update) => {
setMarkdownDocs((current) => {
const next = update(current)
markdownDocsRef.current = next
return next
})
})
markdownSaveSeqRef.current.delete(tab.id)
markdownSaveInFlightRef.current.delete(tab.id)
}
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
sessionTabsRef.current = remainingTabs
setSessionTabs(remainingTabs)
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
@@ -5091,8 +5114,8 @@ export default function SessionScreen() {
{
label: 'Browser',
icon: Globe,
closeBeforePress: true,
onPress: () => {
setShowCreateTabDrawer(false)
if (browserScreencastSupported !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return
@@ -5168,11 +5191,9 @@ export default function SessionScreen() {
{
label: 'Refresh',
icon: RefreshCw,
// Why: dirty refresh opens ConfirmModal; wait for this sheet's native
// Modal to unmount first (same dual-Modal race as tab Rename, #10331).
closeBeforePress: true,
onPress: () => {
const target = markdownActionTarget
setMarkdownActionTarget(null)
if (target) {
discardMarkdownLocalContent(target)
}
+87 -89
View File
@@ -137,9 +137,15 @@ import {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
import {
createMobileItemPrFileContentScope,
createMobileProjectPrFileContentScope,
useMobilePrFileContentCache
} from '../../../src/tasks/use-mobile-pr-file-content-cache'
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
GitHubPRFileContents,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
@@ -358,13 +364,6 @@ type GitHubDetailCheck = {
url?: string | null
}
type GitHubPRFileContents = {
original: string
modified: string
originalIsBinary: boolean
modifiedIsBinary: boolean
}
type DetailPayload =
| {
provider: 'github'
@@ -2263,8 +2262,6 @@ export default function MobileTasksScreen() {
const [itemReviewersDraft, setItemReviewersDraft] = useState('')
const [itemReplyDrafts, setItemReplyDrafts] = useState<Record<string, string>>({})
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -2602,6 +2599,23 @@ export default function MobileTasksScreen() {
() => (projectRowItem ? findProjectRowRepo(projectRowItem) : null),
[findProjectRowRepo, projectRowItem]
)
const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload)
const projectPrFileContentScope = createMobileProjectPrFileContentScope(
projectRowItem,
projectRowHostedRepo,
projectRowDetail,
projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null
)
const activePrFileContentScope = projectRowItem
? projectPrFileContentScope
: itemPrFileContentScope
const {
clear: clearPrFileContents,
contents: prFileContents,
load: loadPrFileContent,
loadingPath: prFileLoadingPath
} = useMobilePrFileContentCache(activePrFileContentScope)
const itemReviewerCandidates = useMemo(() => {
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
return []
@@ -4141,8 +4155,7 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
return
@@ -4157,11 +4170,10 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
}, [actionItem])
}, [actionItem, clearPrFileContents])
useEffect(() => {
if (!detailPayload) {
@@ -4458,8 +4470,7 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setProjectFieldDrafts({})
return
@@ -4474,8 +4485,7 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setProjectFieldDrafts(
Object.fromEntries(
@@ -4579,6 +4589,7 @@ export default function MobileTasksScreen() {
}
}, [
activeGitHubProjectHost,
clearPrFileContents,
client,
githubProjectTable,
projectRowDetailRefreshSeq,
@@ -6549,9 +6560,6 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
if (prFileContents[file.path]) {
return
}
const repo = findProjectRowRepo(row)
if (
!client ||
@@ -6560,49 +6568,45 @@ export default function MobileTasksScreen() {
!row.content.number ||
projectRowDetail?.provider !== 'github' ||
!projectRowDetail.headSha ||
!projectRowDetail.baseSha
!projectRowDetail.baseSha ||
!projectPrFileContentScope
) {
setProjectRowDetailError('Unable to load file contents for this pull request.')
return
}
setPrFileLoadingPath(file.path)
setProjectRowDetailError('')
try {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: projectRowDetail.headSha,
baseSha: projectRowDetail.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setPrFileContents((current) => ({
...current,
[file.path]: response.result as GitHubPRFileContents
}))
} catch (err) {
setProjectRowDetailError(
err instanceof Error ? err.message : 'Failed to load file contents'
)
} finally {
setPrFileLoadingPath(null)
}
await loadPrFileContent(
projectPrFileContentScope,
file,
async () => {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: projectRowDetail.headSha,
baseSha: projectRowDetail.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return response.result
},
setProjectRowDetailError
)
},
[
activeGitHubProjectHost,
client,
expandedPrFilePath,
findProjectRowRepo,
prFileContents,
loadPrFileContent,
projectPrFileContentScope,
projectRowDetail
]
)
@@ -7526,49 +7530,43 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
if (prFileContents[file.path]) {
return
}
if (
!client ||
item.source.type !== 'pr' ||
detailPayload?.provider !== 'github' ||
!detailPayload.headSha ||
!detailPayload.baseSha
!detailPayload.baseSha ||
!itemPrFileContentScope
) {
setError('Unable to load file contents for this pull request.')
return
}
setPrFileLoadingPath(file.path)
setError('')
try {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${item.source.repoId}`,
prNumber: item.source.number,
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: detailPayload.headSha,
baseSha: detailPayload.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setPrFileContents((current) => ({
...current,
[file.path]: response.result as GitHubPRFileContents
}))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file contents')
} finally {
setPrFileLoadingPath(null)
}
await loadPrFileContent(
itemPrFileContentScope,
file,
async () => {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${item.source.repoId}`,
prNumber: item.source.number,
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: detailPayload.headSha,
baseSha: detailPayload.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return response.result
},
setError
)
},
[client, detailPayload, expandedPrFilePath, prFileContents]
[client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent]
)
const addGitHubFileReviewComment = useCallback(
@@ -12322,7 +12320,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]}
contents={prFileContents[file.path]!}
commentDrafts={prFileCommentDrafts}
disabled={projectMutating}
onCommentDraftChange={(draftKey, next) =>
@@ -13268,7 +13266,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]}
contents={prFileContents[file.path]!}
commentDrafts={prFileCommentDrafts}
disabled={mutatingStatus}
onCommentDraftChange={(draftKey, next) =>
@@ -0,0 +1,103 @@
import { Buffer } from 'node:buffer'
export const PROCESS_LINE_MAX_BYTES = 64 * 1024
export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024
const TRUNCATED_LINE_SUFFIX = '… [line truncated]'
export function appendProcessOutputTail(
current,
line,
maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS
) {
if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) {
throw new RangeError('Process output tail limit must be a non-negative safe integer')
}
if (maxCodeUnits === 0) {
return ''
}
const appended = `${current}${line}\n`
return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits)
}
export function attachBoundedProcessLineReader(
stream,
onLine,
maxLineBytes = PROCESS_LINE_MAX_BYTES
) {
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
throw new RangeError('Process line limit must be a non-negative safe integer')
}
const retained = Buffer.allocUnsafe(maxLineBytes)
let retainedBytes = 0
let truncated = false
let swallowLineFeed = false
let closed = false
const append = (bytes, start, end) => {
if (start >= end) {
return
}
const available = maxLineBytes - retainedBytes
const copied = Math.min(available, end - start)
if (copied > 0) {
bytes.copy(retained, retainedBytes, start, start + copied)
retainedBytes += copied
}
truncated ||= copied < end - start
}
const emit = () => {
const line = retained.subarray(0, retainedBytes).toString('utf8')
onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line)
retainedBytes = 0
truncated = false
}
const onData = (chunk) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
let start = 0
if (swallowLineFeed) {
swallowLineFeed = false
if (bytes[0] === 0x0a) {
start = 1
}
}
for (let index = start; index < bytes.length; index += 1) {
const value = bytes[index]
if (value !== 0x0a && value !== 0x0d) {
continue
}
append(bytes, start, index)
emit()
if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) {
index += 1
} else if (value === 0x0d && index + 1 === bytes.length) {
swallowLineFeed = true
}
start = index + 1
}
append(bytes, start, bytes.length)
}
const detach = () => {
if (closed) {
return
}
closed = true
stream.off('data', onData)
stream.off('end', onEnd)
}
const onEnd = () => {
if (retainedBytes > 0 || truncated) {
emit()
}
detach()
}
stream.on('data', onData)
stream.on('end', onEnd)
return detach
}
+36
View File
@@ -0,0 +1,36 @@
export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) {
const declaredLength = response.headers.get('content-length')
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) {
await response.body?.cancel().catch(() => undefined)
return false
}
if (!response.body) {
return false
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let observedBytes = 0
let suffix = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return `${suffix}${decoder.decode()}`.includes(needle)
}
observedBytes += value.byteLength
if (observedBytes > maxBytes) {
await reader.cancel().catch(() => undefined)
return false
}
const candidate = suffix + decoder.decode(value, { stream: true })
if (candidate.includes(needle)) {
await reader.cancel().catch(() => undefined)
return true
}
suffix = candidate.slice(-Math.max(0, needle.length - 1))
}
} finally {
reader.releaseLock()
}
}
@@ -3,7 +3,10 @@ import { mkdirSync, mkdtempSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
function primaryLanIp(lanIpCandidates) {
return lanIpCandidates()[0] || '127.0.0.1'
@@ -66,15 +69,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
let stderr = ''
let resolved = false
let exited = false
let rl = null
let rlErr = null
let closeStdout = () => {}
let closeStderr = () => {}
const stop = () => {
if (!exited) {
child.kill('SIGTERM')
}
rl?.close()
rlErr?.close()
closeStdout()
closeStderr()
child.stdout?.destroy()
child.stderr?.destroy()
}
@@ -119,15 +122,17 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
reject(error)
}
rl = readline.createInterface({ input: child.stdout })
rl.on('line', (line) => {
output += line + '\n'
closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
handleRuntimeLine(line, finishResolve)
})
rlErr = readline.createInterface({ input: child.stderr })
rlErr.on('line', (line) => {
stderr += line + '\n'
closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => {
if (!resolved) {
stderr = appendProcessOutputTail(stderr, line)
}
})
child.on('error', (error) => {
+43 -14
View File
@@ -22,16 +22,21 @@ import os from 'node:os'
import { promisify } from 'node:util'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs'
import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'
const execFileAsync = promisify(execFile)
const DEFAULT_METRO_PORT = 8081
const METRO_PORT_SEARCH_LIMIT = 100
const METRO_STATUS_MAX_BYTES = 64 * 1024
// Parse CLI arguments
const args = process.argv.slice(2)
@@ -113,6 +118,22 @@ function logInfo(message) {
log(`[info] ${message}`, 'yellow')
}
function createBackpressuredLineWriter(source, target, color) {
let waitingForDrain = false
return (line) => {
const accepted = target.write(color + line + colors.reset + '\n')
if (accepted || waitingForDrain) {
return
}
waitingForDrain = true
source.pause()
target.once('drain', () => {
waitingForDrain = false
source.resume()
})
}
}
function assertIosSimulatorPlatform() {
if (process.platform !== 'darwin') {
throw new Error('iOS Simulator automation requires macOS and Xcode.')
@@ -382,8 +403,10 @@ async function startMetro(worktree) {
let url = null
let resolved = false
let exited = false
let rl = null
let rlErr = null
let closeStdout = () => {}
let closeStderr = () => {}
const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim)
const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red)
const metroResult = () => ({
process: metro,
@@ -391,8 +414,8 @@ async function startMetro(worktree) {
output,
isExited: () => exited,
closeOutput: () => {
rl?.close()
rlErr?.close()
closeStdout()
closeStderr()
metro.stdin?.destroy()
metro.stdout?.destroy()
metro.stderr?.destroy()
@@ -400,10 +423,11 @@ async function startMetro(worktree) {
})
// Parse Metro output for the development URL
rl = readline.createInterface({ input: metro.stdout })
rl.on('line', (line) => {
output += line + '\n'
process.stdout.write(colors.dim + line + colors.reset + '\n')
closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
writeStdoutLine(line)
// Look for "Waiting on" message from Metro
// When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL
@@ -446,10 +470,11 @@ async function startMetro(worktree) {
})
// Also check stderr
rlErr = readline.createInterface({ input: metro.stderr })
rlErr.on('line', (line) => {
output += line + '\n'
process.stderr.write(colors.red + line + colors.reset + '\n')
closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
writeStderrLine(line)
})
metro.on('error', (error) => {
@@ -547,7 +572,11 @@ async function verifyMetro(url) {
try {
const response = await fetch(statusUrl, { signal: controller.signal })
return (await response.text()).includes('packager-status:running')
return await responseBodyIncludesWithinLimit(
response,
'packager-status:running',
METRO_STATUS_MAX_BYTES
)
} catch {
return false
} finally {
+16 -61
View File
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -42,6 +39,10 @@ import {
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
} from './mobile-browser-view-mode-state'
import {
createMobileBrowserFrameDataUri,
MobileBrowserFrameCache
} from './mobile-browser-frame-cache'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
@@ -108,14 +109,7 @@ const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
const BROWSER_FRAME_CACHE_LIMIT = 4
type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
const browserFrameCache = new MobileBrowserFrameCache()
type BrowserPageParams = {
worktree: string
@@ -143,7 +137,7 @@ export function MobileBrowserPane({
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
)
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
const cachedInitialFrame = browserFrameCache.peek(cacheKey)
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
const [addressFocused, setAddressFocused] = useState(false)
const [addressSyncState, setAddressSyncState] = useState({
@@ -229,7 +223,7 @@ export function MobileBrowserPane({
const subscription = AppState.addEventListener('change', (nextState) => {
const active = nextState === 'active'
if (!active) {
clearCachedBrowserFramesForWorktree(worktreeId)
browserFrameCache.clearWorktree(worktreeId)
}
setAppActive(active)
})
@@ -280,8 +274,14 @@ export function MobileBrowserPane({
frameMetadataRef.current = frame.metadata
setFrameMetadata(frame.metadata)
}
const nextFrameUri = createBrowserFrameDataUri(frame)
cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
const nextFrameUri = createMobileBrowserFrameDataUri(frame)
if (!nextFrameUri) {
busyRef.current = false
setBusy(false)
setError('Browser frame is too large to display safely.')
return
}
browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
if (!frameMountedRef.current) {
frameUriRef.current = nextFrameUri
frameMountedRef.current = true
@@ -389,7 +389,7 @@ export function MobileBrowserPane({
const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey
lastStreamCacheKeyRef.current = cacheKey
if (!sameStream || !frameUriRef.current) {
const cachedFrame = getCachedBrowserFrame(cacheKey)
const cachedFrame = browserFrameCache.get(cacheKey)
if (cachedFrame) {
frameUriRef.current = cachedFrame.uri
frameMountedRef.current = true
@@ -1303,10 +1303,6 @@ function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
@@ -1315,47 +1311,6 @@ function makeBrowserFrameCacheKey(
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
BrowserScreencastOpcode,
type BrowserScreencastFrame
} from '../transport/browser-screencast-protocol'
import {
createMobileBrowserFrameDataUri,
MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES,
MobileBrowserFrameCache
} from './mobile-browser-frame-cache'
function entry(uri: string) {
return { uri, metadata: {} }
}
function frame(imageBytes: number): BrowserScreencastFrame {
return {
opcode: BrowserScreencastOpcode.Frame,
seq: 1,
format: 'jpeg',
metadata: {},
image: new Uint8Array(imageBytes)
}
}
describe('MobileBrowserFrameCache', () => {
it('preserves LRU behavior below the count and retained-character limits', () => {
const cache = new MobileBrowserFrameCache(2, 100)
cache.set('first', entry('frame-1'))
cache.set('second', entry('frame-2'))
expect(cache.get('first')).toEqual(entry('frame-1'))
cache.set('third', entry('frame-3'))
expect(cache.peek('second')).toBeNull()
expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third'])
})
it('accepts the exact aggregate budget and evicts oldest at one character over', () => {
const cache = new MobileBrowserFrameCache(4, 20)
expect(cache.set('a', entry('x'.repeat(9)))).toBe(true)
expect(cache.set('b', entry('x'.repeat(9)))).toBe(true)
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 })
expect(cache.set('c', entry(''))).toBe(true)
expect(cache.peek('a')).toBeNull()
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 })
})
it('does not retain one entry larger than the full budget', () => {
const cache = new MobileBrowserFrameCache(4, 10)
expect(cache.set('key', entry('x'.repeat(8)))).toBe(false)
expect(cache.evidence()).toEqual({
entryCount: 0,
retainedCharacters: 0,
keysOldestFirst: []
})
})
})
describe('createMobileBrowserFrameDataUri', () => {
it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => {
expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024)
expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==')
expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull()
})
})
@@ -0,0 +1,134 @@
import { Buffer } from 'buffer'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024
export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4
export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024
export type MobileBrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
export type MobileBrowserFrameCacheEvidence = {
entryCount: number
retainedCharacters: number
keysOldestFirst: string[]
}
type RetainedFrame = {
entry: MobileBrowserFrameCacheEntry
retainedCharacters: number
}
export class MobileBrowserFrameCache {
private readonly entries = new Map<string, RetainedFrame>()
private retainedCharacters = 0
constructor(
private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES,
private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS
) {
if (
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isSafeInteger(maxRetainedCharacters) ||
maxRetainedCharacters < 1
) {
throw new Error('Mobile browser frame cache limits must be positive integers')
}
}
get(key: string | null): MobileBrowserFrameCacheEntry | null {
if (!key) {
return null
}
const retained = this.entries.get(key)
if (!retained) {
return null
}
this.entries.delete(key)
this.entries.set(key, retained)
return retained.entry
}
peek(key: string | null): MobileBrowserFrameCacheEntry | null {
return key ? (this.entries.get(key)?.entry ?? null) : null
}
set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean {
if (!key) {
return false
}
const retainedCharacters = key.length + entry.uri.length
const previous = this.entries.get(key)
if (previous) {
this.retainedCharacters -= previous.retainedCharacters
this.entries.delete(key)
}
if (retainedCharacters > this.maxRetainedCharacters) {
return false
}
this.entries.set(key, { entry, retainedCharacters })
this.retainedCharacters += retainedCharacters
this.evictOverflow()
return this.entries.has(key)
}
clearWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
this.delete(key)
}
}
}
clear(): void {
this.entries.clear()
this.retainedCharacters = 0
}
evidence(): MobileBrowserFrameCacheEvidence {
return {
entryCount: this.entries.size,
retainedCharacters: this.retainedCharacters,
keysOldestFirst: [...this.entries.keys()]
}
}
private delete(key: string): void {
const retained = this.entries.get(key)
if (!retained) {
return
}
this.retainedCharacters -= retained.retainedCharacters
this.entries.delete(key)
}
private evictOverflow(): void {
while (
this.entries.size > this.maxEntries ||
this.retainedCharacters > this.maxRetainedCharacters
) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
this.delete(oldestKey)
}
}
}
export function createMobileBrowserFrameDataUri(
frame: BrowserScreencastFrame,
maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES
): string | null {
if (frame.image.byteLength > maxImageBytes) {
return null
}
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { MobileRpcListCache } from './mobile-rpc-list-cache'
describe('MobileRpcListCache', () => {
it('preserves values below the limits and expires them at the existing age boundary', () => {
const cache = new MobileRpcListCache(100, 2, 3, 100)
const values = [{ id: 'repo' }]
expect(cache.set('host', values, 1_000)).toBe(true)
expect(cache.get('host', 1_100)).toBe(values)
expect(cache.get('host', 1_101)).toBeNull()
})
it('accepts the exact item cap and rejects one over', () => {
const cache = new MobileRpcListCache(100, 2, 3, 1_000)
const exact = [1, 2, 3]
expect(cache.set('host', exact)).toBe(true)
expect(cache.get('host')).toBe(exact)
expect(cache.set('host', [...exact, 4])).toBe(false)
expect(cache.get('host')).toBeNull()
})
it('accepts the exact aggregate byte budget and evicts oldest at one over', () => {
const probe = new MobileRpcListCache(100, 3, 3, 1_000)
probe.set('first', ['a'])
const firstBytes = probe.evidence().retainedBytes
probe.clear()
const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2)
expect(cache.set('first', ['a'])).toBe(true)
expect(cache.set('other', ['a'])).toBe(true)
expect(cache.evidence().retainedBytes).toBe(firstBytes * 2)
expect(cache.set('x', [])).toBe(true)
expect(cache.get('first')).toBeNull()
expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2)
})
it('rejects a single payload larger than the full byte budget', () => {
const cache = new MobileRpcListCache(100, 2, 3, 20)
expect(cache.set('host', ['a'.repeat(100)])).toBe(false)
expect(cache.evidence()).toEqual({
entryCount: 0,
retainedBytes: 0,
keysOldestFirst: []
})
})
})
+126
View File
@@ -0,0 +1,126 @@
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
export type MobileRpcListCacheEvidence = {
entryCount: number
retainedBytes: number
keysOldestFirst: string[]
}
type CacheEntry = {
values: unknown[]
at: number
retainedBytes: number
}
export class MobileRpcListCache {
private readonly entries = new Map<string, CacheEntry>()
private retainedBytes = 0
constructor(
private readonly maxAgeMs: number,
private readonly maxEntries: number,
private readonly maxItemsPerEntry: number,
private readonly maxRetainedBytes: number
) {
if (
!Number.isFinite(maxAgeMs) ||
maxAgeMs < 0 ||
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isInteger(maxItemsPerEntry) ||
maxItemsPerEntry < 1 ||
!Number.isSafeInteger(maxRetainedBytes) ||
maxRetainedBytes < 1
) {
throw new Error('Mobile RPC list cache limits must be positive')
}
}
set(key: string, values: unknown[], now = Date.now()): boolean {
this.delete(key)
if (values.length > this.maxItemsPerEntry) {
return false
}
let serialized: string
try {
serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes)
} catch {
return false
}
const retainedBytes = utf8ByteLength(serialized)
if (retainedBytes > this.maxRetainedBytes) {
return false
}
this.entries.set(key, { values, at: now, retainedBytes })
this.retainedBytes += retainedBytes
this.evictOverflow()
return this.entries.has(key)
}
get(key: string, now = Date.now()): unknown[] | null {
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (now - entry.at > this.maxAgeMs) {
this.delete(key)
return null
}
return entry.values
}
clear(): void {
this.entries.clear()
this.retainedBytes = 0
}
evidence(): MobileRpcListCacheEvidence {
return {
entryCount: this.entries.size,
retainedBytes: this.retainedBytes,
keysOldestFirst: [...this.entries.keys()]
}
}
private delete(key: string): void {
const entry = this.entries.get(key)
if (!entry) {
return
}
this.retainedBytes -= entry.retainedBytes
this.entries.delete(key)
}
private evictOverflow(): void {
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
this.delete(oldestKey)
}
}
}
function utf8ByteLength(value: string): number {
let bytes = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x7f) {
bytes += 1
} else if (code <= 0x7ff) {
bytes += 2
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index += 1
} else {
bytes += 3
}
} else {
bytes += 3
}
}
return bytes
}
+20 -2
View File
@@ -1,8 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCachedRepos, setCachedRepos } from './repo-cache'
import {
getCachedRepos,
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
resetRepoCacheForTests,
setCachedRepos
} from './repo-cache'
describe('repo cache', () => {
beforeEach(() => {
resetRepoCacheForTests()
})
it('returns recent host-scoped repos', () => {
const repos = [{ id: 'repo-1' }]
@@ -23,4 +32,13 @@ describe('repo cache', () => {
vi.useRealTimers()
}
})
it('retains the exact per-host item cap and rejects one over', () => {
const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null)
setCachedRepos('host', exact)
expect(getCachedRepos('host')).toBe(exact)
setCachedRepos('host', [...exact, null])
expect(getCachedRepos('host')).toBeNull()
})
})
+18 -24
View File
@@ -2,35 +2,29 @@
// host-scoped cache lets workspace creation open from the last known list while
// a fresh repo.list refresh happens in the background.
type CachedRepos = {
repos: unknown[]
at: number
}
const cache = new Map<string, CachedRepos>()
import { MobileRpcListCache } from './mobile-rpc-list-cache'
const MAX_AGE_MS = 60_000
const MAX_ENTRIES = 20
export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20
export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000
export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
const cache = new MobileRpcListCache(
MAX_AGE_MS,
MOBILE_REPO_CACHE_MAX_ENTRIES,
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
MOBILE_REPO_CACHE_MAX_RETAINED_BYTES
)
export function setCachedRepos(hostId: string, repos: unknown[]): void {
cache.delete(hostId)
cache.set(hostId, { repos, at: Date.now() })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
}
}
cache.set(hostId, repos)
}
export function getCachedRepos(hostId: string): unknown[] | null {
const entry = cache.get(hostId)
if (!entry) {
return null
}
if (Date.now() - entry.at > MAX_AGE_MS) {
cache.delete(hostId)
return null
}
return entry.repos
return cache.get(hostId)
}
/** Test-only: clear process-lifetime cache state between cases. */
export function resetRepoCacheForTests(): void {
cache.clear()
}
+20 -2
View File
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
import { beforeEach, describe, expect, it } from 'vitest'
import {
getCachedWorktrees,
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
resetWorktreeCacheForTests,
setCachedWorktrees
} from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
beforeEach(() => {
resetWorktreeCacheForTests()
})
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
@@ -40,4 +49,13 @@ describe('worktree-cache write-through', () => {
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
it('retains the exact per-host item cap and rejects one over', () => {
const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null)
setCachedWorktrees('host', exact)
expect(getCachedWorktrees('host')).toBe(exact)
setCachedWorktrees('host', [...exact, null])
expect(getCachedWorktrees('host')).toBeNull()
})
})
+18 -27
View File
@@ -2,38 +2,29 @@
// so the host detail page can render instantly on navigation instead of
// waiting for a fresh RPC connection + fetch cycle.
type CachedWorktrees = {
worktrees: unknown[]
at: number
}
const cache = new Map<string, CachedWorktrees>()
import { MobileRpcListCache } from './mobile-rpc-list-cache'
const MAX_AGE_MS = 30_000
const MAX_ENTRIES = 20
export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20
export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000
export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
const cache = new MobileRpcListCache(
MAX_AGE_MS,
MOBILE_WORKTREE_CACHE_MAX_ENTRIES,
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES
)
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
// Why: Map.set on an existing key does not move it to the end of iteration
// order. Delete first so the re-inserted key becomes the newest entry,
// giving us true LRU eviction when the cap is hit.
cache.delete(hostId)
cache.set(hostId, { worktrees, at: Date.now() })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
}
}
cache.set(hostId, worktrees)
}
export function getCachedWorktrees(hostId: string): unknown[] | null {
const entry = cache.get(hostId)
if (!entry) {
return null
}
if (Date.now() - entry.at > MAX_AGE_MS) {
cache.delete(hostId)
return null
}
return entry.worktrees
return cache.get(hostId)
}
/** Test-only: clear process-lifetime cache state between cases. */
export function resetWorktreeCacheForTests(): void {
cache.clear()
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
describe('parseMarkdownBlocks', () => {
it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => {
@@ -100,6 +100,42 @@ describe('parseMarkdownBlocks tables', () => {
])
})
it('accepts the exact table column limit and falls back one column later', () => {
const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) =>
String(index)
).join('|')
const delimiter = Array.from(
{ length: COMMENT_MARKDOWN_LIMITS.tableColumns },
() => '---'
).join('|')
expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({
kind: 'table',
headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns }
})
expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({
kind: 'paragraph'
})
})
it('falls back to a bounded preview before splitting oversized input', () => {
const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits)
const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1)
const [block] = parseMarkdownBlocks(content)
expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }])
expect(block).toMatchObject({ kind: 'paragraph' })
expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan(
COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100
)
})
it('checks normalized break tags before allocating line arrays', () => {
const content = '<br>'.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1)
expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' })
})
it('reads per-column alignment from the delimiter row', () => {
const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n')
const block = parseMarkdownBlocks(md)[0]
@@ -40,6 +40,20 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
const SUMMARY = /<summary\b[^>]*>([\s\S]*?)<\/summary>/i
export const COMMENT_MARKDOWN_LIMITS = {
sourceCodeUnits: 1024 * 1024,
lines: 10_000,
detailsDepth: 64,
tableColumns: 256,
tableRows: 2000,
tableCells: 50_000,
fallbackCodeUnits: 64 * 1024
} as const
const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]'
class CommentMarkdownLimitError extends Error {}
// Removes residual HTML tags from rendered text so stray <b>/<kbd>/<sub> etc. don't
// show literally. Conservative: only matches `<tag ...>` / `</tag>` shapes, so a bare
// "a < b" in prose is left alone.
@@ -48,9 +62,56 @@ export function stripHtmlTags(text: string): string {
}
export function parseMarkdownBlocks(content: string): MarkdownBlock[] {
if (!isCommentMarkdownWithinPreparseLimits(content)) {
return commentMarkdownLimitFallback(content)
}
// Drop HTML comments and normalize <br> before block parsing.
const cleaned = content.replace(/<!--[\s\S]*?-->/g, '').replace(/<br\s*\/?>/gi, '\n')
return parseSegment(cleaned)
if (!isCommentMarkdownWithinPreparseLimits(cleaned)) {
return commentMarkdownLimitFallback(content)
}
try {
return parseSegment(cleaned)
} catch (error) {
if (error instanceof CommentMarkdownLimitError) {
return commentMarkdownLimitFallback(content)
}
throw error
}
}
function isCommentMarkdownWithinPreparseLimits(content: string): boolean {
if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) {
return false
}
let lines = 1
let detailsDepth = 0
const detailsPattern = /<\/?details\b/gi
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue
}
lines += 1
if (lines > COMMENT_MARKDOWN_LIMITS.lines) {
return false
}
}
for (const match of content.matchAll(detailsPattern)) {
if (match[0][1] === '/') {
detailsDepth = Math.max(0, detailsDepth - 1)
continue
}
detailsDepth += 1
if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) {
return false
}
}
return true
}
function commentMarkdownLimitFallback(content: string): MarkdownBlock[] {
const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits)
return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }]
}
// Splits a segment at top-level <details>/<blockquote> regions (preserving order),
@@ -120,8 +181,17 @@ function parseLines(content: string): MarkdownBlock[] {
const align = parseAlignRow(lines[i + 1])
i += 2
const rows: string[][] = []
let tableCells = headers.length
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
rows.push(splitTableRow(lines[i]))
if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) {
throw new CommentMarkdownLimitError('Table row limit exceeded')
}
const row = splitTableRow(lines[i])
tableCells += row.length
if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) {
throw new CommentMarkdownLimitError('Table cell limit exceeded')
}
rows.push(row)
i += 1
}
blocks.push({ kind: 'table', headers, rows, align })
@@ -207,12 +277,18 @@ function splitTableRow(line: string): string[] {
continue
}
if (ch === '|') {
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
throw new CommentMarkdownLimitError('Table column limit exceeded')
}
cells.push(cell.trim())
cell = ''
continue
}
cell += ch
}
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
throw new CommentMarkdownLimitError('Table column limit exceeded')
}
cells.push(cell.trim())
return cells
}
@@ -2,6 +2,7 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback'
import type { MobileDirEntry } from './file-tree'
import type { RpcResponse } from '../transport/types'
@@ -206,6 +207,18 @@ describe('MobileFileExplorerPanel', () => {
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('rejects an extreme directory listing instead of silently truncating it', async () => {
const client = createMockClient({
'': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`))
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile')
expect(renderedText(renderer)).toContain('10,000 items')
})
it('keeps the loaded tree visible during a transient disconnect', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
@@ -366,6 +379,43 @@ describe('MobileFileExplorerPanel', () => {
expect(renderedText(renderer)).toContain('Showing first 5000')
})
it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
if (method === 'files.readDir') {
return {
id: 'response-id',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-id' }
}
}
return {
id: 'response-id',
ok: true,
result: {
files: [
{
relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1),
basename: 'oversized',
kind: 'text'
}
],
totalCount: 1,
truncated: false
},
_meta: { runtimeId: 'runtime-id' }
}
})
}
mockTransport.client = legacyClient
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('legacy file list is too large')
expect(renderedText(renderer)).toContain('Update Orca Desktop')
})
it('reports the files.list failure when the fallback itself fails', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
+84 -46
View File
@@ -16,14 +16,15 @@ import {
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
type FileExplorerRow,
type MobileDirEntry
type DirectoryState,
type FileExplorerRow
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { colors } from '../theme/mobile-theme'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -36,6 +37,12 @@ import {
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
import {
MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE,
parseBoundedMobileDirectoryEntries,
removeEvictedExpandedPaths,
retainMobileDirectoryState
} from './mobile-directory-cache-retention'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -54,13 +61,48 @@ export function MobileFileExplorerPanel(props: {
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const directoryCacheAccessRef = useRef(0)
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const expandedRef = useRef(expanded)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const commitDirectoryState = useCallback(
(relativePath: string, state: DirectoryState): boolean => {
const retained = retainMobileDirectoryState(
directoryCacheRef.current,
relativePath,
{ ...state, lastAccess: ++directoryCacheAccessRef.current },
expandedRef.current
)
if (!retained.admitted) {
return false
}
directoryCacheRef.current = retained.cache
setDirectoryCache(retained.cache)
if (retained.evictedPaths.length > 0) {
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths)
for (const pendingPath of pendingDirectoryRetriesRef.current) {
if (
retained.evictedPaths.some(
(evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`)
)
) {
pendingDirectoryRetriesRef.current.delete(pendingPath)
}
}
const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths)
expandedRef.current = nextExpanded
setExpanded(nextExpanded)
}
return true
},
[]
)
const loadDirectory = useCallback(
async (relativePath: string) => {
const scope = scopeRef.current
@@ -77,13 +119,10 @@ export function MobileFileExplorerPanel(props: {
// Why: transient reconnects should not blank an already browsable tree.
setError(hasLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
error: message
})
}
return
}
@@ -98,13 +137,16 @@ export function MobileFileExplorerPanel(props: {
}
setError(null)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
loading: true
}
}))
const admittedLoading = commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
loading: true
})
if (!admittedLoading) {
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath])
setLoading(false)
setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
return
}
try {
const response = await client.sendRequest('files.readDir', {
@@ -133,7 +175,9 @@ export function MobileFileExplorerPanel(props: {
return
}
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
const legacyCache = directoryCacheFromFileList(legacyResult.files)
directoryCacheRef.current = legacyCache
setDirectoryCache(legacyCache)
// Why: the capped list silently omits files past the cap — keep
// the legacy explorer's "Showing first 5000" note.
setLegacyListTruncated(legacyResult.truncated)
@@ -150,14 +194,13 @@ export function MobileFileExplorerPanel(props: {
) {
return
}
const entries = (response as RpcSuccess).result as MobileDirEntry[]
const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result)
if (rootLoad) {
setLegacyListTruncated(false)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: { entries }
}))
if (!commitDirectoryState(relativePath, { entries })) {
throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
}
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
@@ -170,13 +213,10 @@ export function MobileFileExplorerPanel(props: {
// only a cold load surfaces the full-screen error.
setError(hadLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
error: message
})
}
} finally {
if (
@@ -187,7 +227,7 @@ export function MobileFileExplorerPanel(props: {
}
}
},
[client, connState, worktreeId]
[client, commitDirectoryState, connState, worktreeId]
)
useEffect(() => {
@@ -195,17 +235,15 @@ export function MobileFileExplorerPanel(props: {
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
directoryCacheAccessRef.current = 0
setDirectoryCache({})
setExpanded(new Set())
expandedRef.current = new Set()
setExpanded(expandedRef.current)
setLoading(true)
setError(null)
setLegacyListTruncated(false)
}, [scope])
useEffect(() => {
directoryCacheRef.current = directoryCache
}, [directoryCache])
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
@@ -228,21 +266,21 @@ export function MobileFileExplorerPanel(props: {
const toggleDirectory = useCallback(
(relativePath: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
}
return next
})
const wasExpanded = expandedRef.current.has(relativePath)
const nextExpanded = new Set(expandedRef.current)
if (wasExpanded) {
nextExpanded.delete(relativePath)
} else {
nextExpanded.add(relativePath)
}
expandedRef.current = nextExpanded
setExpanded(nextExpanded)
const state = getDirectoryCacheState(directoryCache, relativePath)
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) {
void loadDirectory(relativePath)
}
},
[directoryCache, expanded, loadDirectory]
[directoryCache, loadDirectory]
)
const retryDirectory = useCallback(
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -41,4 +42,17 @@ describe('directory-load-revisions', () => {
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
})
it('invalidates evicted branches without staling unrelated loads', () => {
const revisions = createDirectoryLoadRevisions()
const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib')
const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs')
forgetDirectoryLoadBranches(revisions, ['src'])
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true)
})
})
@@ -19,6 +19,21 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions):
revisions.revisionsByPath.clear()
}
export function forgetDirectoryLoadBranches(
revisions: DirectoryLoadRevisions,
relativePaths: readonly string[]
): void {
for (const loadedPath of revisions.revisionsByPath.keys()) {
if (
relativePaths.some(
(relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`)
)
) {
revisions.revisionsByPath.delete(loadedPath)
}
}
}
export function beginDirectoryLoad(
revisions: DirectoryLoadRevisions,
scope: string,
+152 -10
View File
@@ -1,7 +1,34 @@
import { describe, expect, it } from 'vitest'
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
import {
directoryCacheFromFileList,
isMobileMethodUnavailableError,
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES,
LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES,
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES,
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE,
LEGACY_MOBILE_FILE_LIST_MAX_FILES,
LEGACY_MOBILE_FILE_PATH_MAX_BYTES,
LEGACY_MOBILE_FILE_PATH_MAX_DEPTH,
type LegacyMobileFileEntry
} from './file-list-fallback'
import { getDirectoryCacheState } from './file-tree'
const RETAINED_NODE_ESTIMATE_BYTES = 64
function file(relativePath: string): LegacyMobileFileEntry {
return { relativePath, basename: relativePath, kind: 'text' }
}
function directoryChainPath(prefix: string, directories: number): string {
const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd'))
return `${names.join('/')}/file`
}
function fixedLengthUniqueName(index: number, length: number): string {
const suffix = `-${index.toString(36)}`
return `${'x'.repeat(length - suffix.length)}${suffix}`
}
describe('isMobileMethodUnavailableError', () => {
it('detects old-desktop allowlist and missing-method failures', () => {
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
@@ -20,9 +47,9 @@ describe('isMobileMethodUnavailableError', () => {
describe('directoryCacheFromFileList', () => {
it('synthesizes every ancestor directory from flat paths', () => {
const cache = directoryCacheFromFileList([
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
file('src/lib/util.ts'),
file('src/app.ts'),
file('README.md')
])
expect(cache['']?.entries).toEqual(
expect.arrayContaining([
@@ -39,11 +66,27 @@ describe('directoryCacheFromFileList', () => {
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('keeps a name a directory when it appears as both file and dir prefix', () => {
it('preserves empty-segment filtering and first-seen entry order', () => {
const cache = directoryCacheFromFileList([
{ relativePath: 'src', basename: 'src', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
file('//src///lib//util.ts//'),
file('/README.md'),
file('src/app.ts'),
file('///')
])
expect(cache['']?.entries).toEqual([
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
])
expect(cache['src']?.entries).toEqual([
{ name: 'lib', isDirectory: true },
{ name: 'app.ts', isDirectory: false }
])
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('keeps a name a directory when it appears as both file and dir prefix', () => {
const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')])
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
})
@@ -53,13 +96,112 @@ describe('directoryCacheFromFileList', () => {
})
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
const cache = directoryCacheFromFileList([
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
])
const cache = directoryCacheFromFileList([file('__proto__/pollute.js')])
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
{ name: 'pollute.js', isDirectory: false }
])
})
it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => {
const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))])
expect(cache['']?.entries).toEqual([])
})
it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => {
const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2)
expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([
{ name: exactPath, isDirectory: false }
])
expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('accepts the exact path depth and rejects the next segment', () => {
const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/')
const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/'))
expect(
getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath)
).toEqual({
entries: [{ name: 'd', isDirectory: false }]
})
expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('rejects a response above the desktop files.list record cap', () => {
const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file(''))
expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
})
it('accepts the exact directory cap and rejects one additional directory', () => {
const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255)
const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255
const files = Array.from({ length: fullChains }, (_, index) =>
file(directoryChainPath(`root-${index}`, 255))
)
if (remainingDirectories > 0) {
files.push(file(directoryChainPath('tail', remainingDirectories)))
}
expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength(
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES
)
expect(() =>
directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))])
).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
})
it('accepts the exact aggregate entry cap and rejects one additional entry', () => {
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) =>
file(`root-${index}/a/b/file`)
)
const overflowFiles = exactFiles.map((entry, index) =>
index === 0 ? file('root-0/a/b/c/file') : entry
)
const cache = directoryCacheFromFileList(exactFiles)
const entryCount = Object.values(cache).reduce(
(total, state) => total + (state?.entries.length ?? 0),
0
)
expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES)
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('accepts the exact retained-byte budget and rejects two bytes over', () => {
const namesBytes =
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES -
RETAINED_NODE_ESTIMATE_BYTES -
LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES
const namesCharacters = namesBytes / 2
const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES)
const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => {
const length = baseLength + (index < longerNames ? 1 : 0)
return file(fixedLengthUniqueName(index, length))
})
const overflowFiles = exactFiles.map((entry, index) =>
index === 0 ? file(`${entry.relativePath}x`) : entry
)
expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength(
LEGACY_MOBILE_FILE_LIST_MAX_FILES
)
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('rejects malformed legacy response shapes with a clear error', () => {
expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list')
expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow(
'invalid legacy file list'
)
})
})
+168 -36
View File
@@ -1,8 +1,22 @@
// Fallback for desktops that predate files.readDir in the mobile RPC
// allowlist: synthesize the lazy directory cache from the flat, capped
// files.list result so the Files tab stays browsable against old desktops.
import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/memory-safety/utf8-byte-limits'
import type { DirectoryCache, MobileDirEntry } from './file-tree'
// Why: each flat legacy path can amplify into many retained ancestor records.
export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000
export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024
export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256
export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384
export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000
export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE =
'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.'
const DIRECTORY_RETAINED_BYTES = 64
const ENTRY_RETAINED_BYTES = 64
export type LegacyMobileFileEntry = {
relativePath: string
basename: string
@@ -15,6 +29,13 @@ export type LegacyFilesListResult = {
truncated: boolean
}
type LegacyFileCacheBuildState = {
childrenByDir: Map<string, Map<string, MobileDirEntry>>
directories: number
entries: number
retainedBytes: number
}
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
// 'forbidden' = method exists but is not mobile-allowlisted on the old
// desktop; 'method_not_found' = desktop predates the method entirely.
@@ -29,45 +50,156 @@ export function isMobileMethodUnavailableError(
)
}
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
const childrenByDir = new Map<string, Map<string, boolean>>()
const ensureDir = (path: string): Map<string, boolean> => {
let children = childrenByDir.get(path)
if (!children) {
children = new Map()
childrenByDir.set(path, children)
}
return children
export function directoryCacheFromFileList(files: unknown): DirectoryCache {
if (!Array.isArray(files)) {
throw new Error('Desktop returned an invalid legacy file list.')
}
ensureDir('')
if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) {
throwLegacyFileListLimitError()
}
const state: LegacyFileCacheBuildState = {
childrenByDir: new Map(),
directories: 0,
entries: 0,
retainedBytes: 0
}
ensureDirectory(state, '')
for (const file of files) {
const parts = file.relativePath.split('/').filter(Boolean)
let parentPath = ''
parts.forEach((name, index) => {
const isDirectory = index < parts.length - 1
const children = ensureDir(parentPath)
children.set(name, children.get(name) === true || isDirectory)
parentPath = parentPath ? `${parentPath}/${name}` : name
if (isDirectory) {
ensureDir(parentPath)
const relativePath = getLegacyRelativePath(file)
if (relativePath === null) {
throw new Error('Desktop returned an invalid legacy file list.')
}
addFilePath(state, relativePath, measureBoundedPathDepth(relativePath))
}
return createDirectoryCache(state.childrenByDir)
}
function getLegacyRelativePath(value: unknown): string | null {
if (value === null || typeof value !== 'object') {
return null
}
const relativePath = (value as { relativePath?: unknown }).relativePath
return typeof relativePath === 'string' ? relativePath : null
}
function measureBoundedPathDepth(relativePath: string): number {
let bytes = 0
let depth = 0
let insideSegment = false
for (let index = 0; index < relativePath.length; index += 1) {
const codePoint = relativePath.codePointAt(index) ?? 0
bytes += getUtf8ByteLengthForCodePoint(codePoint)
if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) {
throwLegacyFileListLimitError()
}
if (codePoint === 47) {
if (insideSegment) {
depth += 1
assertPathDepth(depth)
}
insideSegment = false
} else {
insideSegment = true
}
if (codePoint > 0xffff) {
index += 1
}
}
if (insideSegment) {
depth += 1
assertPathDepth(depth)
}
return depth
}
function assertPathDepth(depth: number): void {
if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) {
throwLegacyFileListLimitError()
}
}
function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void {
let parentPath = ''
let segmentStart = 0
let segmentIndex = 0
for (let cursor = 0; cursor <= relativePath.length; cursor += 1) {
if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) {
continue
}
if (cursor > segmentStart) {
segmentIndex += 1
const name = relativePath.slice(segmentStart, cursor)
const isDirectory = segmentIndex < depth
addDirectoryEntry(state, parentPath, name, isDirectory)
if (isDirectory) {
parentPath = parentPath ? `${parentPath}/${name}` : name
ensureDirectory(state, parentPath)
}
}
segmentStart = cursor + 1
}
}
function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void {
if (state.childrenByDir.has(path)) {
return
}
const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES
if (
state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES ||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
) {
throwLegacyFileListLimitError()
}
state.childrenByDir.set(path, new Map())
state.directories += 1
state.retainedBytes += retainedBytes
}
function addDirectoryEntry(
state: LegacyFileCacheBuildState,
parentPath: string,
name: string,
isDirectory: boolean
): void {
const children = state.childrenByDir.get(parentPath)
if (!children) {
throw new Error('Legacy file cache builder lost its parent directory.')
}
const existing = children.get(name)
if (existing) {
existing.isDirectory ||= isDirectory
return
}
const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES
if (
state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES ||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
) {
throwLegacyFileListLimitError()
}
children.set(name, { name, isDirectory })
state.entries += 1
state.retainedBytes += retainedBytes
}
function createDirectoryCache(
childrenByDir: ReadonlyMap<string, ReadonlyMap<string, MobileDirEntry>>
): DirectoryCache {
const cache: DirectoryCache = {}
for (const [path, children] of childrenByDir) {
// Why: assignment to a '__proto__' path would invoke its legacy setter.
Object.defineProperty(cache, path, {
configurable: true,
enumerable: true,
value: { entries: Array.from(children.values()) },
writable: true
})
}
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
// object's prototype instead of storing the directory; fromEntries always
// creates own keys.
return Object.fromEntries(
Array.from(childrenByDir, ([path, children]) => [
path,
{
entries: Array.from(
children,
([name, isDirectory]): MobileDirEntry => ({
name,
isDirectory
})
)
}
])
)
return cache
}
function throwLegacyFileListLimitError(): never {
throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
}
+1
View File
@@ -22,6 +22,7 @@ export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
lastAccess?: number
}
export type DirectoryCache = Record<string, DirectoryState | undefined>
@@ -1,44 +1,57 @@
import { describe, expect, it } from 'vitest'
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
function pngBase64(width = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(1, 20)
return bytes.toString('base64')
}
describe('mobileDiffImageDataUri', () => {
it('renders a modified image diff from the post-change bytes', () => {
const modifiedContent = pngBase64(2)
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
modifiedContent,
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
).toBe(`data:image/png;base64,${modifiedContent}`)
})
it('renders an added image diff (no original) from the modified bytes', () => {
const modifiedContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'bmV3',
modifiedContent,
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
).toBe(`data:image/png;base64,${modifiedContent}`)
})
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
const originalContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
originalContent,
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
modifiedDeleted: true,
isImage: true,
mimeType: 'image/jpeg'
mimeType: 'image/png'
})
).toBe('data:image/jpeg;base64,b2xk')
).toBe(`data:image/png;base64,${originalContent}`)
})
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { DirectoryCache, DirectoryState } from './file-tree'
import {
MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
parseBoundedMobileDirectoryEntries,
removeEvictedExpandedPaths,
retainMobileDirectoryState
} from './mobile-directory-cache-retention'
function state(name: string, lastAccess: number): DirectoryState {
return { entries: [{ name, isDirectory: false }], lastAccess }
}
describe('mobile directory cache retention', () => {
it('keeps the cache bounded across an unlimited sequence of visited directories', () => {
let cache: DirectoryCache = { '': state('root', 0) }
for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) {
const result = retainMobileDirectoryState(
cache,
`dir-${index}`,
state(`file-${index}`, index),
new Set()
)
expect(result.admitted).toBe(true)
cache = result.cache
}
expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES)
expect(cache['']).toBeDefined()
expect(cache['dir-1']).toBeUndefined()
expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined()
})
it('evicts a collapsed branch before an older expanded branch', () => {
const cache: DirectoryCache = {
'': state('root', 0),
expanded: state('expanded', 1),
collapsed: state('collapsed', 2)
}
const result = retainMobileDirectoryState(
cache,
'new',
state('new', 3),
new Set(['expanded']),
{ directories: 3, entries: 100, retainedBytes: 10_000 }
)
expect(result.cache.expanded).toBeDefined()
expect(result.cache.collapsed).toBeUndefined()
expect(result.evictedPaths).toEqual(['collapsed'])
})
it('evicts old content when aggregate entry retention reaches its cap', () => {
const cache: DirectoryCache = {
'': state('root', 0),
old: state('old', 1)
}
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
directories: 10,
entries: 2,
retainedBytes: 10_000
})
expect(result.cache.old).toBeUndefined()
expect(result.cache.new).toBeDefined()
})
it('evicts old content when aggregate retained bytes reach their cap', () => {
const cache: DirectoryCache = {
'': state('root', 0),
old: state('x'.repeat(100), 1)
}
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
directories: 10,
entries: 100,
retainedBytes: 300
})
expect(result.cache.old).toBeUndefined()
expect(result.cache.new).toBeDefined()
})
it('collapses expanded descendants whose cached branch was evicted', () => {
expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([
'docs'
])
})
it('rejects malformed listings instead of retaining untrusted response shapes', () => {
expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow(
'invalid folder listing'
)
})
})
@@ -0,0 +1,156 @@
import {
assertMobileFileDirectoryWithinLimit,
estimateMobileDirectoryEntryBytes,
MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE,
MOBILE_FILE_DIRECTORY_MAX_ENTRIES
} from '../../../src/shared/mobile-file-directory-limit'
import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree'
// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling.
export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128
export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000
export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE =
'Too many folders are open to load this folder safely. Close another folder and retry.'
type CacheLimits = {
directories: number
entries: number
retainedBytes: number
}
type RetentionResult = {
cache: DirectoryCache
evictedPaths: string[]
admitted: boolean
}
const DEFAULT_LIMITS: CacheLimits = {
directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES,
retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES
}
export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] {
if (!Array.isArray(value)) {
throw new Error('Desktop returned an invalid folder listing.')
}
if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) {
throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE)
}
for (const entry of value) {
if (!isMobileDirectoryEntry(entry)) {
throw new Error('Desktop returned an invalid folder listing.')
}
}
const entries = value as MobileDirEntry[]
assertMobileFileDirectoryWithinLimit(entries)
return entries
}
export function retainMobileDirectoryState(
cache: DirectoryCache,
relativePath: string,
state: DirectoryState,
expandedPaths: ReadonlySet<string>,
limits: CacheLimits = DEFAULT_LIMITS
): RetentionResult {
const next: DirectoryCache = { ...cache, [relativePath]: state }
const evictedPaths: string[] = []
const essentialPaths = directoryAncestors(relativePath)
while (cacheExceedsLimits(next, limits)) {
const victim = selectEvictionPath(next, expandedPaths, essentialPaths)
if (victim === null) {
return { cache, evictedPaths: [], admitted: false }
}
for (const path of Object.keys(next)) {
if (path === victim || path.startsWith(`${victim}/`)) {
delete next[path]
evictedPaths.push(path)
}
}
}
return { cache: next, evictedPaths, admitted: true }
}
export function removeEvictedExpandedPaths(
expandedPaths: ReadonlySet<string>,
evictedPaths: readonly string[]
): Set<string> {
if (evictedPaths.length === 0) {
return new Set(expandedPaths)
}
return new Set(
[...expandedPaths].filter(
(expanded) =>
!evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`))
)
)
}
function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry {
if (!value || typeof value !== 'object') {
return false
}
const entry = value as Record<string, unknown>
return (
typeof entry.name === 'string' &&
typeof entry.isDirectory === 'boolean' &&
(entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
)
}
function directoryAncestors(relativePath: string): Set<string> {
const ancestors = new Set(['', relativePath])
let cursor = relativePath
while (cursor.includes('/')) {
cursor = cursor.slice(0, cursor.lastIndexOf('/'))
ancestors.add(cursor)
}
return ancestors
}
function selectEvictionPath(
cache: DirectoryCache,
expandedPaths: ReadonlySet<string>,
essentialPaths: ReadonlySet<string>
): string | null {
const candidates = Object.keys(cache)
.filter((path) => !essentialPaths.has(path))
.sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right))
return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null
}
function accessOrder(cache: DirectoryCache, path: string): number {
return cache[path]?.lastAccess ?? 0
}
function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean {
const paths = Object.keys(cache)
if (paths.length > limits.directories) {
return true
}
let entries = 0
let retainedBytes = 0
for (const path of paths) {
const state = cache[path]
if (!state) {
continue
}
entries += state.entries.length
retainedBytes += estimateDirectoryStateBytes(path, state)
if (entries > limits.entries || retainedBytes > limits.retainedBytes) {
return true
}
}
return false
}
function estimateDirectoryStateBytes(path: string, state: DirectoryState): number {
let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64
for (const entry of state.entries) {
bytes += estimateMobileDirectoryEntryBytes(entry)
}
return bytes
}
@@ -28,6 +28,16 @@ function clientWithResponses(responses: RpcResponse[]) {
}
}
function pngBase64(width = 1, height = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(height, 20)
return bytes.toString('base64')
}
describe('mobile-file-preview-request', () => {
it('selects readPreview for raster images and read for text-like files', () => {
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
@@ -45,14 +55,13 @@ describe('mobile-file-preview-request', () => {
})
it('loads images through readPreview and never calls files.open', async () => {
const client = clientWith(
ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' })
)
const content = pngBase64()
const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' }))
await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({
status: 'ready',
kind: 'image',
dataUri: 'data:image/png;base64,aW1hZ2U='
dataUri: `data:image/png;base64,${content}`
})
expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', {
worktree: 'id:wt-1',
@@ -527,9 +536,9 @@ describe('mobile-file-preview-request', () => {
})
it.each([
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }],
['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }],
['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }]
])('rejects invalid image preview results: %s', (_label, result) => {
expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({
@@ -539,6 +548,24 @@ describe('mobile-file-preview-request', () => {
})
})
it('rejects an oversized raster response before React Native receives a data URI', () => {
expect(
normalizeMobileFilePreviewResponse(
'assets/logo.png',
ok({
content: pngBase64(32_769, 1),
isBinary: true,
isImage: true,
mimeType: 'image/png'
})
)
).toEqual({
status: 'error',
message: 'Binary preview unavailable',
reconnect: false
})
})
it('normalizes markdown, html, text, empty, and truncated reads', () => {
expect(
normalizeMobileFilePreviewResponse(
@@ -2,6 +2,7 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { isMarkdownPath } from './file-tree'
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
@@ -117,10 +118,14 @@ function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
) {
return previewError('binary_file')
}
const dataUri = buildImageDataUri(preview.mimeType, preview.content)
if (!dataUri) {
return previewError('binary_file')
}
return {
status: 'ready',
kind: 'image',
dataUri: `data:${preview.mimeType};base64,${preview.content}`
dataUri
}
}
+24 -4
View File
@@ -31,6 +31,16 @@ function clientOf(byMethod: Record<string, RpcResponse>): {
const WT = { worktreeId: 'wt1' }
function pngBase64(width = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(1, 20)
return bytes.toString('base64')
}
describe('resolveMobileFileTabDoc', () => {
it('renders a staged text diff', async () => {
const client = clientOf({
@@ -46,11 +56,12 @@ describe('resolveMobileFileTabDoc', () => {
})
it('renders an unstaged image diff from the modified bytes', async () => {
const modifiedContent = pngBase64()
const client = clientOf({
'git.diff': ok({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
modifiedContent,
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
@@ -61,7 +72,11 @@ describe('resolveMobileFileTabDoc', () => {
relativePath: 'm1.png',
diffSource: 'unstaged'
})
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
expect(doc).toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${modifiedContent}`
})
})
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
@@ -88,11 +103,16 @@ describe('resolveMobileFileTabDoc', () => {
})
it('renders a live image preview via files.readPreview', async () => {
const content = pngBase64()
const client = clientOf({
'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
'files.readPreview': ok({ content, isImage: true, mimeType: 'image/png' })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
expect(doc).toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${content}`
})
expect(client.calls).toEqual(['files.readPreview'])
})
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
enqueueMobileDictationAudioChunk,
MOBILE_DICTATION_MAX_PENDING_CHUNKS
} from './mobile-dictation-audio-chunk'
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
describe('enqueueMobileDictationAudioChunk', () => {
it('accepts the exact pending-promise cap and rejects one over even for empty chunks', () => {
const sendRequest = vi.fn(() => new Promise<never>(() => undefined))
const failActiveDictation = vi.fn()
const pendingChunks = new Set<Promise<void>>()
const queue = {
pendingChunks,
pendingAudioBudget: new MobileDictationPendingAudioBudget(),
shouldReleaseBudget: () => true,
failActiveDictation
}
const event = { data: new Uint8Array() }
for (let index = 0; index < MOBILE_DICTATION_MAX_PENDING_CHUNKS; index += 1) {
enqueueMobileDictationAudioChunk(
{ sendRequest } as unknown as RpcClient,
'dictation',
event,
queue
)
}
expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
expect(failActiveDictation).not.toHaveBeenCalled()
enqueueMobileDictationAudioChunk(
{ sendRequest } as unknown as RpcClient,
'dictation',
event,
queue
)
expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
expect(failActiveDictation).toHaveBeenCalledOnce()
})
})
@@ -14,12 +14,21 @@ type MobileDictationAudioChunkQueue = {
failActiveDictation: (dictationId: string, err: unknown) => void
}
export const MOBILE_DICTATION_MAX_PENDING_CHUNKS = 256
export function enqueueMobileDictationAudioChunk(
client: RpcClient,
dictationId: string,
event: MicrophoneDataEvent,
queue: MobileDictationAudioChunkQueue
): void {
if (queue.pendingChunks.size >= MOBILE_DICTATION_MAX_PENDING_CHUNKS) {
queue.failActiveDictation(
dictationId,
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
)
return
}
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
import {
buildMobileAiVaultResumeLaunch,
buildMobileAiVaultResumeCommand,
@@ -121,50 +120,6 @@ describe('buildMobileAiVaultResumeCommand', () => {
})
describe('buildMobileAiVaultResumeLaunch', () => {
it('preserves an arbitrary OMP transcript locator for later cold resume', () => {
const launch = buildMobileAiVaultResumeLaunch({
session: session({
agent: 'omp',
sessionId: 'omp-custom-1',
filePath: '/custom/omp-sessions/project/session.jsonl'
}),
hostPlatform: 'linux',
settings: {
agentDefaultArgs: { omp: '--model custom' },
agentDefaultEnv: { omp: { OMP_PROFILE: 'custom' } }
}
})
expect(launch).toMatchObject({
command:
"cd '/Users/ada/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'",
env: { OMP_PROFILE: 'custom' },
launchConfig: {
agentCommand: "omp '--model' 'custom'",
agentArgs: '--model custom',
agentEnv: { OMP_PROFILE: 'custom' },
ompResumeFilePath: '/custom/omp-sessions/project/session.jsonl'
},
launchAgent: 'omp'
})
const coldLaunch = buildAgentResumeStartupPlan({
agent: 'omp',
providerSession: { key: 'session_id', id: 'omp-custom-1' },
cmdOverrides: {},
agentArgs: launch.launchConfig?.agentArgs,
agentEnv: launch.launchConfig?.agentEnv,
agentCommand: launch.launchConfig?.agentCommand,
ompResumeFilePath: launch.launchConfig?.ompResumeFilePath,
platform: 'linux'
})
expect(coldLaunch).toMatchObject({
launchCommand:
"omp '--model' 'custom' '--resume' '/custom/omp-sessions/project/session.jsonl'",
env: { OMP_PROFILE: 'custom' }
})
})
it('uses shared TUI startup planning for default args, env, and launch config', () => {
const launch = buildMobileAiVaultResumeLaunch({
session: session({
+9 -25
View File
@@ -15,7 +15,6 @@ import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../src/shared/tui-agent-launch-defaults'
import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-resume-path'
import type { TuiAgent } from '../../../src/shared/types'
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell'
@@ -59,7 +58,7 @@ export function buildMobileAiVaultResumeCommand(args: {
sessionId: args.session.sessionId,
// Why: OMP resumes by absolute transcript path (custom OMP dir / WSL-store
// sessions miss on an id lookup), so mobile forwards it like desktop does.
resumeFilePath: normalizeAiVaultResumeFilePath(args.session.filePath, args.hostPlatform),
resumeFilePath: args.session.filePath,
cwd: args.session.cwd,
platform: args.hostPlatform,
commandOverride: args.commandOverride,
@@ -98,7 +97,6 @@ export function buildMobileAiVaultResumeLaunch(args: {
args.settings?.agentCmdOverrides
)
const commandOverride = cmdOverrides[args.session.agent] ?? null
const resumeFilePath = normalizeAiVaultResumeFilePath(args.session.filePath, args.hostPlatform)
if (isResumableTuiAgent(args.session.agent)) {
const startupPlan = buildAgentResumeStartupPlan({
agent: args.session.agent,
@@ -107,31 +105,17 @@ export function buildMobileAiVaultResumeLaunch(args: {
platform: args.hostPlatform,
shell,
agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv),
...(args.session.agent === 'omp' && resumeFilePath
? { ompResumeFilePath: resumeFilePath }
: {})
agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv)
})
if (startupPlan) {
return {
command:
args.session.agent === 'omp'
? buildMobileAiVaultResumeCommand({
session: {
...args.session,
...(resumeFilePath ? { filePath: resumeFilePath } : {})
},
hostPlatform: args.hostPlatform,
hostTerminalWindowsShell: args.hostTerminalWindowsShell,
commandOverride: startupPlan.launchConfig.agentCommand
})
: buildAiVaultResumeShellCommand({
resumeCommand: startupPlan.launchCommand,
cwd: args.session.cwd,
platform: args.hostPlatform,
codexHome,
shell
}),
command: buildAiVaultResumeShellCommand({
resumeCommand: startupPlan.launchCommand,
cwd: args.session.cwd,
platform: args.hostPlatform,
codexHome,
shell
}),
...(startupPlan.env ? { env: startupPlan.env } : {}),
// Why: the resume command is typed into the created pane, so the bare
// real-home override must strip Codex homes at pane spawn like desktop.
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
describe('MobileImageBase64Accumulator', () => {
it('preserves bytes delivered as 100,000 one-byte fragments', () => {
const accumulator = new MobileImageBase64Accumulator()
const expected = Buffer.alloc(100_000)
for (let index = 0; index < expected.byteLength; index += 1) {
const value = index % 251
expected[index] = value
accumulator.append(Uint8Array.of(value))
}
expect(accumulator.finish()).toBe(expected.toString('base64'))
})
})
@@ -0,0 +1,40 @@
import { Buffer } from 'buffer'
const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1
export class MobileImageBase64Accumulator {
private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES)
private readonly encodedChunks: string[] = []
private stagingLength = 0
append(bytes: Uint8Array): void {
let offset = 0
while (offset < bytes.byteLength) {
const copied = Math.min(
this.staging.byteLength - this.stagingLength,
bytes.byteLength - offset
)
this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength)
this.stagingLength += copied
offset += copied
if (this.stagingLength === this.staging.byteLength) {
this.flushStaging()
}
}
}
finish(): string {
this.flushStaging()
return this.encodedChunks.join('')
}
private flushStaging(): void {
if (this.stagingLength === 0) {
return
}
this.encodedChunks.push(
Buffer.from(this.staging.subarray(0, this.stagingLength)).toString('base64')
)
this.stagingLength = 0
}
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
vi.mock('expo-image-picker', () => ({
requestMediaLibraryPermissionsAsync: vi.fn(),
@@ -7,6 +8,9 @@ vi.mock('expo-image-picker', () => ({
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn()
}))
vi.mock('expo-file-system', () => ({
File: vi.fn()
}))
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
@@ -15,17 +19,48 @@ const granted = { granted: true } as Awaited<
>
const denied = { granted: false } as typeof granted
function fileFactory(
chunks: Uint8Array[],
options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
) {
const close = vi.fn()
const readBytes = vi.fn(() => {
if (options?.readError) {
throw options.readError
}
return chunks.shift() ?? new Uint8Array()
})
const open = vi.fn(() => ({
size: options?.handleSize ?? options?.fileSize ?? 0,
readBytes,
close
}))
const createFile = vi.fn(() => ({ size: options?.fileSize ?? 0, open }))
return { close, createFile, open, readBytes }
}
describe('pickMobileImage', () => {
it('returns base64 from the photo library', async () => {
const bytes = new Uint8Array([0, 1, 2, 3])
const file = fileFactory([bytes])
const launchLibrary = vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }]
})
const result = await pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }]
})
launchLibrary,
createFile: file.createFile
})
expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' })
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///x.jpg'
})
expect(launchLibrary).toHaveBeenCalledWith(
expect.objectContaining({ base64: false, allowsMultipleSelection: false })
)
expect(file.close).toHaveBeenCalledTimes(1)
})
it('throws when photo library permission is denied', async () => {
@@ -48,22 +83,25 @@ describe('pickMobileImage', () => {
it('reads a picked file URI into base64 for the files source', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } }))
const file = fileFactory([bytes])
const launchFiles = vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///doc.png', size: bytes.length }]
})
const result = await pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///doc.png' }]
})
launchFiles,
createFile: file.createFile
})
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///doc.png'
})
fetchSpy.mockRestore()
expect(launchFiles).toHaveBeenCalledWith(
expect.objectContaining({ copyToCacheDirectory: true })
)
expect(file.close).toHaveBeenCalledTimes(1)
})
it('returns null when the files picker is cancelled', async () => {
@@ -73,4 +111,72 @@ describe('pickMobileImage', () => {
expect(result).toBeNull()
})
it('rejects a declared oversized asset before opening it', async () => {
const file = fileFactory([], { fileSize: 1 })
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }]
}),
createFile: file.createFile
})
).rejects.toThrow('Clipboard image is too large')
expect(file.createFile).not.toHaveBeenCalled()
expect(file.open).not.toHaveBeenCalled()
})
it('does not let stale size metadata bypass the bounded read', async () => {
const close = vi.fn()
const readBytes = vi.fn((length: number) => new Uint8Array(length))
const createFile = vi.fn(() => ({
size: 1,
open: () => ({ size: 1, readBytes, close })
}))
await expect(
pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///grew.png', fileSize: 1 }]
}),
createFile
})
).rejects.toThrow('Clipboard image is too large')
expect(readBytes).toHaveBeenLastCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
})
it('closes the file handle when a read fails', async () => {
const file = fileFactory([], { fileSize: 4, readError: new Error('read failed') })
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///broken.png', size: 4 }]
}),
createFile: file.createFile
})
).rejects.toThrow('read failed')
expect(file.close).toHaveBeenCalledTimes(1)
})
it('preserves bytes across chunk boundaries that are not base64 aligned', async () => {
const chunks = [new Uint8Array([1]), new Uint8Array([2, 3]), new Uint8Array([4, 5])]
const file = fileFactory([...chunks], { fileSize: 5, handleSize: 5 })
const result = await pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///chunked.png', size: 5 }]
}),
createFile: file.createFile
})
expect(result).toEqual({
base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'),
uri: 'file:///chunked.png'
})
expect(file.close).toHaveBeenCalledTimes(1)
})
})
@@ -1,8 +1,12 @@
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import * as DocumentPicker from 'expo-document-picker'
import { File as FsFile } from 'expo-file-system'
import * as ImagePicker from 'expo-image-picker'
import {
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
assertClipboardImageBase64LengthWithinLimit,
assertClipboardImageByteLengthWithinLimit
} from '../../../src/shared/clipboard-image'
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
export type MobileImageSource = 'library' | 'files'
@@ -21,18 +25,68 @@ export class ImageLibraryPermissionError extends Error {
}
}
// Why: expo-document-picker returns a file URI, not base64. Read it through
// fetch + Buffer so we match the base64 contract the upload pipeline expects
// without pulling in expo-file-system.
async function readUriAsBase64(uri: string): Promise<string> {
const response = await fetch(uri)
const bytes = new Uint8Array(await response.arrayBuffer())
return Buffer.from(bytes).toString('base64')
const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024
type MobileImageFileHandle = {
readonly size: number | null
readBytes(length: number): Uint8Array
close(): void
}
type MobileImageFile = {
readonly size: number
open(): MobileImageFileHandle
}
export type MobileImageFileFactory = (uri: string) => MobileImageFile
function defaultMobileImageFileFactory(uri: string): MobileImageFile {
return new FsFile(uri)
}
async function readUriAsBase64(
uri: string,
declaredSize: number | undefined,
createFile: MobileImageFileFactory
): Promise<string> {
if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) {
assertClipboardImageByteLengthWithinLimit(declaredSize)
}
const file = createFile(uri)
assertClipboardImageByteLengthWithinLimit(file.size)
const handle = file.open()
try {
if (handle.size !== null) {
assertClipboardImageByteLengthWithinLimit(handle.size)
}
const accumulator = new MobileImageBase64Accumulator()
let bytesRead = 0
while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) {
const requested = Math.min(
MOBILE_IMAGE_READ_CHUNK_BYTES,
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1
)
const bytes = handle.readBytes(requested)
if (bytes.byteLength === 0) {
break
}
bytesRead += bytes.byteLength
assertClipboardImageByteLengthWithinLimit(bytesRead)
accumulator.append(bytes)
}
const base64 = accumulator.finish()
assertClipboardImageBase64LengthWithinLimit(base64.length)
return base64
} finally {
handle.close()
}
}
async function pickFromLibrary(
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise<PickedMobileImage | null> {
const permission = await requestPermission()
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
@@ -41,7 +95,7 @@ async function pickFromLibrary(
}
const result = await launch({
mediaTypes: ['images'],
base64: true,
base64: false,
allowsMultipleSelection: false,
quality: 1
})
@@ -49,7 +103,7 @@ async function pickFromLibrary(
return null
}
const asset = result.assets[0]
const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
if (!base64) {
return null
}
@@ -57,7 +111,8 @@ async function pickFromLibrary(
}
async function pickFromFiles(
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise<PickedMobileImage | null> {
const result = await launch({
type: 'image/*',
@@ -71,7 +126,8 @@ async function pickFromFiles(
if (!asset?.uri) {
return null
}
return { base64: await readUriAsBase64(asset.uri), uri: asset.uri }
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
return base64 ? { base64, uri: asset.uri } : null
}
export async function pickMobileImage(
@@ -80,10 +136,11 @@ export async function pickMobileImage(
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
readonly createFile?: MobileImageFileFactory
}
): Promise<PickedMobileImage | null> {
if (source === 'library') {
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
}
return pickFromFiles(deps?.launchFiles)
return pickFromFiles(deps?.launchFiles, deps?.createFile)
}
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import {
MobileSessionFileDocLifecycle,
beginMobileFileDocLoad,
createMobileFileDocLifecycle,
finishMobileFileDocLoad,
forgetMobileFileDocTab,
reconcileMobileFileDocTabs,
removeMobileFileDoc,
resetMobileFileDocLifecycle,
retainLiveMobileFileDocs,
type MobileFileTabIdentity
} from './mobile-session-file-doc-lifecycle'
import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
type TestFileDoc = MobileFileTabDoc | { status: 'loading' } | { status: 'error'; message: string }
function fileTab(id: string, relativePath = `${id}.ts`): MobileFileTabIdentity {
return {
id,
filePath: `/repo/${relativePath}`,
relativePath,
mode: 'edit'
}
}
describe('mobile session file document lifecycle', () => {
it('retains payloads only for file tabs in the accepted live snapshot', () => {
const lifecycle = createMobileFileDocLifecycle()
reconcileMobileFileDocTabs(lifecycle, [fileTab('live-a'), fileTab('live-b')])
const docs = new Map([
['closed', { payload: 'x'.repeat(1_000_000) }],
['live-a', { payload: 'a' }],
['live-b', { payload: 'b' }]
])
const retained = retainLiveMobileFileDocs(docs, lifecycle)
expect([...retained.keys()]).toEqual(['live-a', 'live-b'])
expect(retained.get('live-a')).toBe(docs.get('live-a'))
})
it('deletes a successfully closed tab immediately', () => {
const lifecycle = createMobileFileDocLifecycle()
reconcileMobileFileDocTabs(lifecycle, [fileTab('closed')])
forgetMobileFileDocTab(lifecycle, 'closed')
expect(removeMobileFileDoc(new Map([['closed', { payload: 'large' }]]), 'closed').size).toBe(0)
})
it('rejects a late read after its tab closes', () => {
const lifecycle = createMobileFileDocLifecycle()
const tab = fileTab('file')
reconcileMobileFileDocTabs(lifecycle, [tab])
const token = beginMobileFileDocLoad(lifecycle, tab)
expect(token).not.toBeNull()
forgetMobileFileDocTab(lifecycle, tab.id)
expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
})
it('keeps route state empty when a read resolves after close', async () => {
const lifecycle = new MobileSessionFileDocLifecycle()
const tab = fileTab('file')
let docs = new Map<string, TestFileDoc>()
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
docs = update(docs)
}
lifecycle.reconcile([{ ...tab, type: 'file' }], updateDocs)
let resolveRead: (doc: MobileFileTabDoc) => void = () => undefined
const read = new Promise<MobileFileTabDoc>((resolve) => {
resolveRead = resolve
})
const pending = lifecycle.load(tab, updateDocs, () => read)
expect(docs.get(tab.id)).toEqual({ status: 'loading' })
lifecycle.close(tab.id, updateDocs)
resolveRead({ status: 'ready', kind: 'file', content: 'late', truncated: false, byteLength: 4 })
await pending
expect(docs.size).toBe(0)
})
it('rejects a late read when the same tab id is replaced with another file', () => {
const lifecycle = createMobileFileDocLifecycle()
const oldTab = fileTab('file', 'old.ts')
reconcileMobileFileDocTabs(lifecycle, [oldTab])
const token = beginMobileFileDocLoad(lifecycle, oldTab)
expect(token).not.toBeNull()
const replaced = reconcileMobileFileDocTabs(lifecycle, [fileTab('file', 'replacement.ts')])
expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
expect(
retainLiveMobileFileDocs(
new Map([['file', { payload: 'old contents' }]]),
lifecycle,
replaced
)
).toEqual(new Map())
})
it('lets only the newest read for a live tab commit', () => {
const lifecycle = createMobileFileDocLifecycle()
const tab = fileTab('file')
reconcileMobileFileDocTabs(lifecycle, [tab])
const first = beginMobileFileDocLoad(lifecycle, tab)
const second = beginMobileFileDocLoad(lifecycle, tab)
expect(finishMobileFileDocLoad(lifecycle, first!)).toBe(false)
expect(finishMobileFileDocLoad(lifecycle, second!)).toBe(true)
})
it('invalidates requests when the route scope resets even if tab ids are reused', () => {
const lifecycle = createMobileFileDocLifecycle()
const tab = fileTab('reused')
reconcileMobileFileDocTabs(lifecycle, [tab])
const oldScope = beginMobileFileDocLoad(lifecycle, tab)
resetMobileFileDocLifecycle(lifecycle)
reconcileMobileFileDocTabs(lifecycle, [tab])
const newScope = beginMobileFileDocLoad(lifecycle, tab)
expect(finishMobileFileDocLoad(lifecycle, oldScope!)).toBe(false)
expect(finishMobileFileDocLoad(lifecycle, newScope!)).toBe(true)
})
})
@@ -0,0 +1,242 @@
import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
export type MobileFileTabIdentity = {
id: string
filePath: string
relativePath: string
mode?: 'edit' | 'diff'
diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
}
export type MobileFileDocLoadToken = {
generation: number
requestId: number
tabId: string
tabIdentity: string
}
export type MobileFileDocLifecycle = {
generation: number
nextRequestId: number
liveTabIdentityById: Map<string, string>
activeRequestIdByTabId: Map<string, number>
}
type MobileFileTabCandidate = {
type: string
id: string
filePath?: unknown
relativePath?: unknown
mode?: unknown
diffSource?: unknown
}
type MobileSessionFileDoc =
| MobileFileTabDoc
| { status: 'loading' }
| { status: 'error'; message: string }
type MobileSessionFileDocUpdater = (
update: (current: Map<string, MobileSessionFileDoc>) => Map<string, MobileSessionFileDoc>
) => void
export class MobileSessionFileDocLifecycle {
private readonly lifecycle = createMobileFileDocLifecycle()
reconcile(
tabs: readonly MobileFileTabCandidate[],
updateDocs: MobileSessionFileDocUpdater
): void {
const replacedTabIds = reconcileMobileFileDocTabs(
this.lifecycle,
tabs.filter(isMobileFileTabIdentity)
)
updateDocs((current) => retainLiveMobileFileDocs(current, this.lifecycle, replacedTabIds))
}
async load(
tab: MobileFileTabIdentity,
updateDocs: MobileSessionFileDocUpdater,
read: () => Promise<MobileFileTabDoc>
): Promise<void> {
const token = beginMobileFileDocLoad(this.lifecycle, tab)
if (!token) {
return
}
updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
try {
const doc = await read()
if (finishMobileFileDocLoad(this.lifecycle, token)) {
updateDocs((current) => new Map(current).set(tab.id, doc))
}
} catch (error) {
if (finishMobileFileDocLoad(this.lifecycle, token)) {
updateDocs((current) =>
new Map(current).set(tab.id, {
status: 'error',
message: getMobileFileDocLoadErrorMessage(tab, error)
})
)
}
}
}
close(tabId: string, updateDocs: MobileSessionFileDocUpdater): void {
forgetMobileFileDocTab(this.lifecycle, tabId)
updateDocs((current) => removeMobileFileDoc(current, tabId))
}
reset(): void {
resetMobileFileDocLifecycle(this.lifecycle)
}
}
export function createMobileFileDocLifecycle(): MobileFileDocLifecycle {
return {
generation: 0,
nextRequestId: 0,
liveTabIdentityById: new Map(),
activeRequestIdByTabId: new Map()
}
}
export function reconcileMobileFileDocTabs(
lifecycle: MobileFileDocLifecycle,
tabs: readonly MobileFileTabIdentity[]
): ReadonlySet<string> {
const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)]))
const replacedTabIds = new Set<string>()
for (const tabId of lifecycle.activeRequestIdByTabId.keys()) {
const nextIdentity = nextIdentityById.get(tabId)
const currentIdentity = lifecycle.liveTabIdentityById.get(tabId)
if (!nextIdentity || nextIdentity !== currentIdentity) {
lifecycle.activeRequestIdByTabId.delete(tabId)
}
}
for (const [tabId, currentIdentity] of lifecycle.liveTabIdentityById) {
const nextIdentity = nextIdentityById.get(tabId)
if (nextIdentity && nextIdentity !== currentIdentity) {
replacedTabIds.add(tabId)
}
}
lifecycle.liveTabIdentityById.clear()
for (const [tabId, identity] of nextIdentityById) {
lifecycle.liveTabIdentityById.set(tabId, identity)
}
return replacedTabIds
}
export function beginMobileFileDocLoad(
lifecycle: MobileFileDocLifecycle,
tab: MobileFileTabIdentity
): MobileFileDocLoadToken | null {
const tabIdentity = getMobileFileTabIdentity(tab)
if (lifecycle.liveTabIdentityById.get(tab.id) !== tabIdentity) {
return null
}
const requestId = ++lifecycle.nextRequestId
lifecycle.activeRequestIdByTabId.set(tab.id, requestId)
return {
generation: lifecycle.generation,
requestId,
tabId: tab.id,
tabIdentity
}
}
export function finishMobileFileDocLoad(
lifecycle: MobileFileDocLifecycle,
token: MobileFileDocLoadToken
): boolean {
if (!isCurrentMobileFileDocLoad(lifecycle, token)) {
return false
}
lifecycle.activeRequestIdByTabId.delete(token.tabId)
return true
}
export function forgetMobileFileDocTab(lifecycle: MobileFileDocLifecycle, tabId: string): void {
lifecycle.liveTabIdentityById.delete(tabId)
lifecycle.activeRequestIdByTabId.delete(tabId)
}
export function resetMobileFileDocLifecycle(lifecycle: MobileFileDocLifecycle): void {
lifecycle.generation += 1
lifecycle.liveTabIdentityById.clear()
lifecycle.activeRequestIdByTabId.clear()
}
export function retainLiveMobileFileDocs<T>(
docs: Map<string, T>,
lifecycle: MobileFileDocLifecycle,
replacedTabIds?: ReadonlySet<string>
): Map<string, T> {
let next: Map<string, T> | null = null
for (const tabId of docs.keys()) {
if (lifecycle.liveTabIdentityById.has(tabId) && !replacedTabIds?.has(tabId)) {
continue
}
next ??= new Map(docs)
next.delete(tabId)
}
return next ?? docs
}
export function removeMobileFileDoc<T>(docs: Map<string, T>, tabId: string): Map<string, T> {
if (!docs.has(tabId)) {
return docs
}
const next = new Map(docs)
next.delete(tabId)
return next
}
function isCurrentMobileFileDocLoad(
lifecycle: MobileFileDocLifecycle,
token: MobileFileDocLoadToken
): boolean {
return (
lifecycle.generation === token.generation &&
lifecycle.liveTabIdentityById.get(token.tabId) === token.tabIdentity &&
lifecycle.activeRequestIdByTabId.get(token.tabId) === token.requestId
)
}
function getMobileFileTabIdentity(tab: MobileFileTabIdentity): string {
return JSON.stringify([
tab.id,
tab.filePath,
tab.relativePath,
tab.mode ?? '',
tab.diffSource ?? ''
])
}
function isMobileFileTabIdentity(
tab: MobileFileTabCandidate
): tab is MobileFileTabCandidate & MobileFileTabIdentity {
return (
tab.type === 'file' &&
typeof tab.filePath === 'string' &&
typeof tab.relativePath === 'string' &&
(tab.mode === undefined || tab.mode === 'edit' || tab.mode === 'diff') &&
(tab.diffSource === undefined ||
tab.diffSource === 'staged' ||
tab.diffSource === 'unstaged' ||
tab.diffSource === 'branch' ||
tab.diffSource === 'commit')
)
}
function getMobileFileDocLoadErrorMessage(tab: MobileFileTabIdentity, error: unknown): string {
const message = error instanceof Error ? error.message : ''
if (message === 'binary_file') {
return 'Binary preview unavailable'
}
if (message === 'file_too_large') {
return 'File too large for mobile preview'
}
return tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
import { MobileSessionMarkdownDocLifecycle } from './mobile-session-markdown-doc-lifecycle'
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type ReadyMarkdownDoc = Extract<MarkdownDocState, { status: 'ready' }>
function markdownTab(id: string, relativePath = `${id}.md`): MarkdownTab {
return {
type: 'markdown',
id,
title: relativePath,
filePath: `/repo/${relativePath}`,
relativePath,
isDirty: false,
isActive: false,
documentVersion: 'v1'
}
}
function readyDoc(content: string, isDirty = false): ReadyMarkdownDoc {
return {
status: 'ready',
content,
localContent: content,
baseVersion: 'v1',
isDirty,
editable: true
}
}
describe('mobile session markdown document lifecycle', () => {
it('retains payloads only for markdown tabs in the accepted snapshot', () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
let docs = new Map<string, MarkdownDocState>([
['closed', readyDoc('x'.repeat(1_000_000))],
['live', readyDoc('live')]
])
lifecycle.reconcile([markdownTab('live')], (update) => {
docs = update(docs)
})
expect([...docs.keys()]).toEqual(['live'])
})
it('retains a dirty orphan when reconciliation keeps its draft tab', () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
let docs = new Map<string, MarkdownDocState>([['draft', readyDoc('unsaved', true)]])
lifecycle.reconcile([markdownTab('draft')], (update) => {
docs = update(docs)
})
expect(docs.get('draft')).toEqual(readyDoc('unsaved', true))
})
it('does not let a late read resurrect a closed tab', async () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
const tab = markdownTab('late')
let docs = new Map<string, MarkdownDocState>()
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
docs = update(docs)
}
lifecycle.reconcile([tab], updateDocs)
let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
const read = new Promise<ReadyMarkdownDoc>((resolve) => {
resolveRead = resolve
})
const pending = lifecycle.load(tab, updateDocs, () => read)
lifecycle.close(tab.id, updateDocs)
resolveRead(readyDoc('late payload'))
await pending
expect(docs.size).toBe(0)
})
it('does not let an old path overwrite a replacement using the same tab id', async () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
const oldTab = markdownTab('same', 'old.md')
let docs = new Map<string, MarkdownDocState>()
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
docs = update(docs)
}
lifecycle.reconcile([oldTab], updateDocs)
let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
const pending = lifecycle.load(
oldTab,
updateDocs,
() =>
new Promise<ReadyMarkdownDoc>((resolve) => {
resolveRead = resolve
})
)
lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs)
resolveRead(readyDoc('old payload'))
await pending
expect(docs.size).toBe(0)
})
})
@@ -0,0 +1,147 @@
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
type MobileMarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type MobileMarkdownDocUpdater = (
update: (current: Map<string, MarkdownDocState>) => Map<string, MarkdownDocState>
) => void
type MobileMarkdownDocLoadToken = {
generation: number
requestId: number
tabId: string
tabIdentity: string
}
export class MobileSessionMarkdownDocLifecycle {
private generation = 0
private nextRequestId = 0
private readonly liveTabIdentityById = new Map<string, string>()
private readonly activeRequestIdByTabId = new Map<string, number>()
reconcile(tabs: readonly MobileSessionTab[], updateDocs: MobileMarkdownDocUpdater): void {
const nextIdentityById = new Map(
tabs
.filter((tab): tab is MobileMarkdownTab => tab.type === 'markdown')
.map((tab) => [tab.id, markdownTabIdentity(tab)])
)
const replacedTabIds = new Set<string>()
for (const [tabId, currentIdentity] of this.liveTabIdentityById) {
const nextIdentity = nextIdentityById.get(tabId)
if (nextIdentity && nextIdentity !== currentIdentity) {
replacedTabIds.add(tabId)
}
}
for (const tabId of this.activeRequestIdByTabId.keys()) {
if (nextIdentityById.get(tabId) !== this.liveTabIdentityById.get(tabId)) {
this.activeRequestIdByTabId.delete(tabId)
}
}
this.liveTabIdentityById.clear()
for (const [tabId, identity] of nextIdentityById) {
this.liveTabIdentityById.set(tabId, identity)
}
updateDocs((current) => retainLiveMarkdownDocs(current, nextIdentityById, replacedTabIds))
}
async load(
tab: MobileMarkdownTab,
updateDocs: MobileMarkdownDocUpdater,
read: () => Promise<Extract<MarkdownDocState, { status: 'ready' }>>
): Promise<void> {
const token = this.beginLoad(tab)
if (!token) {
return
}
updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
try {
const doc = await read()
if (this.finishLoad(token)) {
updateDocs((current) => new Map(current).set(tab.id, doc))
}
} catch {
if (this.finishLoad(token)) {
updateDocs((current) =>
new Map(current).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
})
)
}
}
}
close(tabId: string, updateDocs: MobileMarkdownDocUpdater): void {
this.liveTabIdentityById.delete(tabId)
this.activeRequestIdByTabId.delete(tabId)
updateDocs((current) => removeMarkdownDoc(current, tabId))
}
reset(): void {
this.generation += 1
this.liveTabIdentityById.clear()
this.activeRequestIdByTabId.clear()
}
private beginLoad(tab: MobileMarkdownTab): MobileMarkdownDocLoadToken | null {
const tabIdentity = markdownTabIdentity(tab)
if (this.liveTabIdentityById.get(tab.id) !== tabIdentity) {
return null
}
const requestId = ++this.nextRequestId
this.activeRequestIdByTabId.set(tab.id, requestId)
return {
generation: this.generation,
requestId,
tabId: tab.id,
tabIdentity
}
}
private finishLoad(token: MobileMarkdownDocLoadToken): boolean {
if (
token.generation !== this.generation ||
this.liveTabIdentityById.get(token.tabId) !== token.tabIdentity ||
this.activeRequestIdByTabId.get(token.tabId) !== token.requestId
) {
return false
}
this.activeRequestIdByTabId.delete(token.tabId)
return true
}
}
function retainLiveMarkdownDocs(
docs: Map<string, MarkdownDocState>,
liveIdentityById: ReadonlyMap<string, string>,
replacedTabIds: ReadonlySet<string>
): Map<string, MarkdownDocState> {
let next: Map<string, MarkdownDocState> | null = null
for (const tabId of docs.keys()) {
if (liveIdentityById.has(tabId) && !replacedTabIds.has(tabId)) {
continue
}
next ??= new Map(docs)
next.delete(tabId)
}
return next ?? docs
}
function removeMarkdownDoc(
docs: Map<string, MarkdownDocState>,
tabId: string
): Map<string, MarkdownDocState> {
if (!docs.has(tabId)) {
return docs
}
const next = new Map(docs)
next.delete(tabId)
return next
}
function markdownTabIdentity(tab: MobileMarkdownTab): string {
return JSON.stringify([tab.id, tab.filePath, tab.relativePath])
}
@@ -5,10 +5,6 @@ const source = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const reconciliationHookSource = readFileSync(
new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -33,23 +29,24 @@ describe('mobile session startup', () => {
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
})
it('delegates stream ownership while retaining the exact terminal polling cadence', () => {
expect(source).toContain('useMobileSessionTabsReconciliation<')
expect(source).toContain('const applicationRevision = ++appliedSessionTabsRevisionRef.current')
expect(source).toContain('getApplicationRevision: getSessionTabsApplicationRevision')
expect(source).not.toContain("client.subscribe(\n 'session.tabs.subscribe'")
expect(reconciliationHookSource).toContain("client.subscribe(\n 'session.tabs.subscribe'")
expect(reconciliationHookSource).toContain(
"if (AppState.currentState !== 'active') {\n controller.setReconciliationActive(false)"
it('stops fallback list polling in the background and reconciles on resume', () => {
const pollEffect = sliceBetween(
"const refreshOnForeground = () => {\n if (AppState.currentState !== 'active')",
'// Why: pick up Settings → Terminal text size on return'
)
expect(reconciliationHookSource).toContain('void controller.poll()')
expect(reconciliationHookSource).toContain('void fetchTerminals()')
expect(reconciliationHookSource).toContain("AppState.addEventListener('change'")
expect(reconciliationHookSource).toContain('const interval = setInterval(')
expect(reconciliationHookSource).toContain('2000')
expect(reconciliationHookSource).toContain('controller.setReconciliationActive(false)')
expect(reconciliationHookSource).toContain('clearInterval(interval)')
expect(reconciliationHookSource).toContain('appStateSubscription.remove()')
expect(pollEffect).toContain(
"if (AppState.currentState !== 'active') {\n return\n }\n void fetchSessionTabs()"
)
expect(pollEffect).toContain('void fetchTerminals()')
expect(pollEffect).toContain("AppState.addEventListener('change'")
expect(pollEffect).toContain("if (state === 'active') {\n refreshOnForeground()")
expect(pollEffect).toContain('const interval = setInterval(refreshOnForeground, 2000)')
expect(pollEffect.lastIndexOf('\n refreshOnForeground()')).toBeGreaterThan(
pollEffect.indexOf("AppState.addEventListener('change'")
)
expect(pollEffect).toContain('clearInterval(interval)')
expect(pollEffect).toContain('appStateSubscription.remove()')
})
it('loads session tabs without waiting for desktop activation', () => {
@@ -65,7 +62,7 @@ describe('mobile session startup', () => {
expect(startupEffect).toContain("navigation: 'caller'")
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await ensureSessionTabs()')
startupEffect.indexOf('await fetchSessionTabs()')
)
expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
@@ -2,7 +2,10 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import {
retainMobileNativeChatFilePaths,
useMobileNativeChatFileSearch
} from './use-mobile-native-chat-file-search'
type SearchState = ReturnType<typeof useMobileNativeChatFileSearch>
@@ -163,3 +166,15 @@ describe('useMobileNativeChatFileSearch', () => {
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
})
})
describe('retainMobileNativeChatFilePaths', () => {
it('accepts exact count and retained-byte limits and truncates at one over', () => {
const result = {
files: [{ relativePath: 'a' }, { relativePath: 'bb' }, { relativePath: 'ignored' }]
}
const exactBytes = 'a'.length * 2 + 64 + ('bb'.length * 2 + 64)
expect(retainMobileNativeChatFilePaths(result, 2, exactBytes)).toEqual(['a', 'bb'])
expect(retainMobileNativeChatFilePaths(result, 3, exactBytes - 1)).toEqual(['a'])
})
})
@@ -5,12 +5,31 @@ import { rankSuggestions } from './mobile-native-chat-autocomplete'
const FILE_SEARCH_DEBOUNCE_MS = 120
const FILE_SEARCH_RESULT_LIMIT = 16
const FILE_SEARCH_QUERY_CACHE_LIMIT = 20
export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS = 50_000
export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
const FILE_SEARCH_RESULT_MAX_RETAINED_BYTES = 256 * 1024
function extractPaths(result: unknown): string[] {
export function retainMobileNativeChatFilePaths(
result: unknown,
maxPaths: number,
maxRetainedBytes: number
): string[] {
const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? []
return files
.map((file) => file.relativePath ?? '')
.filter((path): path is string => path.length > 0)
const paths: string[] = []
let retainedBytes = 0
for (const file of files) {
const path = file.relativePath
if (typeof path !== 'string' || path.length === 0) {
continue
}
const nextBytes = retainedBytes + path.length * 2 + 64
if (paths.length >= maxPaths || nextBytes > maxRetainedBytes) {
break
}
paths.push(path)
retainedBytes = nextBytes
}
return paths
}
/** Debounces current-host path searches, bounds the mobile result/cache, and
@@ -94,7 +113,11 @@ export function useMobileNativeChatFileSearch(args: {
if (!response.ok || generationRef.current !== generation) {
return null
}
const paths = extractPaths(response.result)
const paths = retainMobileNativeChatFilePaths(
response.result,
MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS,
MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES
)
legacyPathsRef.current = paths
return paths
})
@@ -129,7 +152,13 @@ export function useMobileNativeChatFileSearch(args: {
})
if (response.ok) {
searchSupportedRef.current = true
applyPaths(extractPaths(response.result))
applyPaths(
retainMobileNativeChatFilePaths(
response.result,
FILE_SEARCH_RESULT_LIMIT,
FILE_SEARCH_RESULT_MAX_RETAINED_BYTES
)
)
return
}
if (response.error.code === 'method_not_found') {
@@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
// Fully stub the picker so the real expo/react-native chain never loads under
@@ -67,7 +66,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
scopeKey: SCOPE_A,
enabled: true,
showToast: vi.fn(),
baseSend: vi.fn().mockResolvedValue('accepted'),
baseSend: vi.fn().mockResolvedValue(true),
sleep: async () => {},
...overrides
}
@@ -85,9 +84,6 @@ describe('useMobileNativeChatImageAttachments', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
pick.mockReset()
// Stale markers live at module scope now (they outlive the screen), so they
// also outlive a test.
resetMobileNativeChatStaleInputForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
@@ -153,7 +149,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
const baseSend = vi.fn(async (t: string) => {
order.push(`text:${t}`)
return 'accepted' as const
return true
})
// Record each terminal write so the paste-before-settle order is asserted,
// not just implied by the call counts.
@@ -208,7 +204,7 @@ describe('useMobileNativeChatImageAttachments', () => {
sendResult(true), // Ctrl+U clear
sendResult(true) // image paste
])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
@@ -231,7 +227,7 @@ describe('useMobileNativeChatImageAttachments', () => {
it('delegates straight to baseSend when there are no attachments', async () => {
const client = makeClient([])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
@@ -249,7 +245,7 @@ describe('useMobileNativeChatImageAttachments', () => {
sendResult(true), // Ctrl+U clear
sendResult(false) // image paste rejected
])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
const showToast = vi.fn()
mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
await act(async () => {
@@ -269,7 +265,7 @@ describe('useMobileNativeChatImageAttachments', () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
// No terminal.send responses queued: the clear write throws (dropped transport).
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
const showToast = vi.fn()
mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
await act(async () => {
@@ -288,7 +284,7 @@ describe('useMobileNativeChatImageAttachments', () => {
it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
const showToast = vi.fn()
// Attaching is allowed without the lease; only the send is gated on it.
mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast }))
@@ -308,7 +304,7 @@ describe('useMobileNativeChatImageAttachments', () => {
it('scopes chips to the tab that attached them', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
const args = baseArgs({ client: client as unknown as RpcClient, baseSend })
mount(args)
await act(async () => {
@@ -382,7 +378,7 @@ describe('useMobileNativeChatImageAttachments', () => {
methodNotFound('start'),
ok('save', '/tmp/b.png') // second attach, while the send is parked on settle
])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
let releaseSettle: (() => void) | null = null
const args = baseArgs({
client: client as unknown as RpcClient,
@@ -437,7 +433,7 @@ describe('useMobileNativeChatImageAttachments', () => {
sendResult(true), // Ctrl+U clear
sendResult(true) // image paste — into term-1
])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
const showToast = vi.fn()
const activeHandleRef = { current: 'term-1' }
let releaseSettle: (() => void) | null = null
@@ -487,7 +483,7 @@ describe('useMobileNativeChatImageAttachments', () => {
sendResult(false), // image paste rejected — stale input left in term-1
sendResult(true) // healing Ctrl+U before the text-only send
])
const baseSend = vi.fn().mockResolvedValue('accepted')
const baseSend = vi.fn().mockResolvedValue(true)
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
await hook!.attachImage('library')
@@ -514,98 +510,6 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(baseSend).toHaveBeenCalledWith('hi again')
})
it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
sendResult(true), // Ctrl+U clear
sendResult(true), // image paste accepted — path now sits on term-1's input
sendResult(true) // healing Ctrl+U before the follow-up text send
])
const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted')
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
await hook!.attachImage('library')
})
// Ambiguous delivery: the paste landed but the text+Enter may not have.
let accepted = false
await act(async () => {
accepted = await hook!.sendNativeChat('pic')
})
// Mirrors the text path: 'unknown' usually WAS delivered, so the send is not
// surfaced as a failure and the chip does not linger for a double-send retry.
expect(accepted).toBe(true)
expect(hook!.attachments).toEqual([])
// The next plain-text send must Ctrl+U first — if the Enter was lost, the
// orphaned image path would otherwise glue onto this later message.
await act(async () => {
accepted = await hook!.sendNativeChat('later message')
})
expect(accepted).toBe(true)
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
expect(sendCalls).toHaveLength(3)
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg'])
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message')
})
it('still heals after the session screen unmounts and remounts (#10228)', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
sendResult(true), // Ctrl+U clear
sendResult(true), // image paste accepted — path now sits on term-1's input
sendResult(true) // healing Ctrl+U on the remounted screen
])
const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted')
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('pic')
})
// Back out of the session screen and return. The orphaned paste sits on the
// HOST's input line, so a fresh hook must still know to clear it.
act(() => renderer!.unmount())
renderer = null
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
expect(hook!.attachments).toEqual([])
let accepted = false
await act(async () => {
accepted = await hook!.sendNativeChat('later message')
})
expect(accepted).toBe(true)
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
expect(sendCalls).toHaveLength(3)
expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message')
})
it('does not heal after an unknown text-only send (nothing was pasted first)', async () => {
const client = makeClient([])
const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted')
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
let accepted = false
await act(async () => {
accepted = await hook!.sendNativeChat('first')
})
expect(accepted).toBe(true)
await act(async () => {
accepted = await hook!.sendNativeChat('second')
})
expect(accepted).toBe(true)
// No paste preceded the ambiguous send, so no healing Ctrl+U hits the wire.
expect(client.calls).toHaveLength(0)
})
it('retains the stale marker when a rejected healing clear blocks text-only send', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([
@@ -616,7 +520,7 @@ describe('useMobileNativeChatImageAttachments', () => {
sendResult(false), // first healing Ctrl+U rejected
sendResult(true) // retry healing Ctrl+U accepted
])
const baseSend = vi.fn().mockResolvedValueOnce('rejected').mockResolvedValueOnce('accepted')
const baseSend = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
await hook!.attachImage('library')
@@ -661,7 +565,7 @@ describe('useMobileNativeChatImageAttachments', () => {
sendResult(true),
deferredClear
])
const baseSend = vi.fn().mockResolvedValueOnce('rejected')
const baseSend = vi.fn().mockResolvedValueOnce(false)
const activeHandleRef = { current: 'term-1' }
mount(baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend }))
await act(async () => {
@@ -710,10 +614,10 @@ describe('useMobileNativeChatImageAttachments', () => {
])
const baseSend = vi
.fn()
.mockResolvedValueOnce('rejected')
.mockResolvedValueOnce('rejected')
.mockResolvedValueOnce('accepted')
.mockResolvedValueOnce('accepted')
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
const activeHandleRef = { current: 'term-1' }
const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend })
mount(args)
@@ -15,13 +15,6 @@ import {
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
pasteMobileNativeChatImagePaths
} from './mobile-native-chat-image-send'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import {
clearMobileNativeChatInputStale,
healMobileNativeChatStaleInput,
isMobileNativeChatInputStale,
markMobileNativeChatInputStale
} from './mobile-native-chat-stale-input'
type CurrentRef<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -39,14 +32,9 @@ type Args = {
/** The native-chat input lease is ready — same gate `handleNativeChatSend` uses. */
readonly enabled: boolean
readonly showToast: ShowToast
/** The plain text send (controller.handleNativeChatSendWithOutcome); wrapped so
* images ride along. The optional URIs drive the optimistic echo's thumbnails.
* Must preserve 'unknown': after a successful paste, an ambiguously-delivered
* text+Enter may have left the image on the input line, which needs healing. */
readonly baseSend: (
text: string,
imagePreviewUris?: string[]
) => Promise<MobileNativeChatSendOutcome>
/** The plain text send (controller.handleNativeChatSend); wrapped so images ride
* along. The optional URIs drive the optimistic echo's thumbnails. */
readonly baseSend: (text: string, imagePreviewUris?: string[]) => Promise<boolean>
readonly onAttachSuccess?: () => void
readonly onError?: () => void
// Injected so the settle between image paste and submit is instant in tests.
@@ -83,6 +71,10 @@ function withScopeAttachments(
return remaining
}
function markTerminalInputStale(staleInputs: Set<string>, terminal: string): void {
staleInputs.add(terminal)
}
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
@@ -111,6 +103,8 @@ export function useMobileNativeChatImageAttachments({
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
// Terminals whose input may hold a failed paste; each must heal independently.
const staleInputTerminalsRef = useRef(new Set<string>())
// Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
const sendInFlightTerminalsRef = useRef(new Set<string>())
@@ -220,22 +214,31 @@ export function useMobileNativeChatImageAttachments({
// otherwise glue the stale image paste onto this message. Best-effort —
// on failure the marker stays set and the text must not be submitted.
const staleTerminal = activeHandleRef.current
if (staleTerminal && isMobileNativeChatInputStale(staleTerminal) && client) {
const healed = await healMobileNativeChatStaleInput({
client,
terminal: staleTerminal,
deviceToken: deviceTokenRef.current
})
// A tab switch during the clear would send this text to a terminal the
// clear never touched, so abort rather than reroute it.
if (!healed || activeHandleRef.current !== staleTerminal) {
if (staleTerminal && staleInputTerminalsRef.current.has(staleTerminal) && client) {
let cleared = false
try {
cleared = await pasteMobileNativeChatImagePaths({
client,
terminal: staleTerminal,
deviceToken: deviceTokenRef.current,
imagePaths: []
})
} catch {
// Leave marked for the next attempt.
}
if (!cleared) {
onError?.()
showToast('Message not sent', 1500)
return false
}
staleInputTerminalsRef.current.delete(staleTerminal)
if (activeHandleRef.current !== staleTerminal) {
onError?.()
showToast('Message not sent', 1500)
return false
}
}
// Text-only sends paste nothing first, so 'unknown' leaves no stale input.
return (await baseSend(text)) !== 'rejected'
return baseSend(text)
}
const handle = activeHandleRef.current
if (!client || !handle || !enabled || connState !== 'connected') {
@@ -253,13 +256,13 @@ export function useMobileNativeChatImageAttachments({
})
if (!pasted) {
// Keep the chips so the user can retry; the failed paste never submitted.
markMobileNativeChatInputStale(handle)
markTerminalInputStale(staleInputTerminalsRef.current, handle)
onError?.()
showToast('Message not sent', 1500)
return false
}
// The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
clearMobileNativeChatInputStale(handle)
staleInputTerminalsRef.current.delete(handle)
// Let the TUI absorb the image paste before the text + Enter follow. The
// preview URIs ride along to baseSend so the sent bubble shows the photo
// immediately (empty text still submits a bare Enter through baseSend).
@@ -268,25 +271,22 @@ export function useMobileNativeChatImageAttachments({
// route the text + Enter to a different terminal than the images. Abort —
// the chips keep their scope and a retry's Ctrl+U clears the stale paste.
if (activeHandleRef.current !== handle) {
markMobileNativeChatInputStale(handle)
markTerminalInputStale(staleInputTerminalsRef.current, handle)
onError?.()
showToast('Message not sent', 1500)
return false
}
const outcome = await baseSend(
const accepted = await baseSend(
text,
pendingImages.map((attachment) => attachment.previewUri)
)
if (outcome !== 'accepted') {
// 'rejected' leaves the pasted image path on this input line; 'unknown'
// may have lost the text+Enter AFTER the paste landed, orphaning the
// image onto whatever is sent next (#10228) — both must heal first.
markMobileNativeChatInputStale(handle)
if (!accepted) {
// A rejected submit leaves the successfully pasted image path on this input line.
markTerminalInputStale(staleInputTerminalsRef.current, handle)
}
if (outcome !== 'rejected') {
if (accepted) {
// Drop only what rode along — a chip attached while this send was in
// flight keeps waiting for its own send. 'unknown' clears too: the
// send usually DID land, and a kept chip would double-send the image.
// flight keeps waiting for its own send.
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
setAttachmentsByScope((prev) =>
withScopeAttachments(
@@ -296,12 +296,12 @@ export function useMobileNativeChatImageAttachments({
)
)
}
return outcome !== 'rejected'
return accepted
} catch {
// A thrown paste/send (network/RPC) keeps the chips and honors the
// Promise<boolean> contract instead of rejecting. Retry-safe: the next
// attempt's leading Ctrl+U clears whatever fraction of the paste landed.
markMobileNativeChatInputStale(handle)
markTerminalInputStale(staleInputTerminalsRef.current, handle)
onError?.()
showToast('Message not sent', 1500)
return false
@@ -1,7 +1,6 @@
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { MobileImageSource } from './mobile-image-source-picker'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { useMobileImageAttachment } from './use-mobile-image-attachment'
import {
useMobileNativeChatImageAttachments,
@@ -23,12 +22,7 @@ type Args = {
readonly nativeChatInputLeaseReady: boolean
readonly getActiveWorktreeConnectionId: () => Promise<string | null>
readonly beforeTerminalSend: (terminal: string) => Promise<boolean>
/** Outcome-preserving so an ambiguous ('unknown') delivery after an image
* paste can mark the terminal input for healing (#10228). */
readonly nativeChatBaseSend: (
text: string,
images?: string[]
) => Promise<MobileNativeChatSendOutcome>
readonly nativeChatBaseSend: (text: string, images?: string[]) => Promise<boolean>
readonly showToast: (message: string, durationMs?: number) => void
readonly onSuccess: () => void
readonly onError: () => void
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import type { GitHubPRFileContents } from '../../../src/shared/types'
import {
MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES,
MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
MobilePrFileContentCache,
createMobilePrFileContentKey,
createMobilePrFileContentScope,
getMobilePrFileContentByteCount,
getMobilePrFileContentsForScope
} from './mobile-pr-file-content-cache'
import {
createMobileItemPrFileContentScope,
createMobileProjectPrFileContentScope
} from './use-mobile-pr-file-content-cache'
function contents(original: string, modified = ''): GitHubPRFileContents {
return {
original,
modified,
originalIsBinary: false,
modifiedIsBinary: false
}
}
const scopeA = createMobilePrFileContentScope({
source: 'item',
repoId: 'repo-1',
prNumber: 10,
headSha: 'head-a',
baseSha: 'base-a'
})
const scopeB = createMobilePrFileContentScope({
source: 'item',
repoId: 'repo-1',
prNumber: 11,
headSha: 'head-b',
baseSha: 'base-a'
})
describe('MobilePrFileContentCache', () => {
it('derives route scopes only from complete GitHub PR revisions', () => {
expect(
createMobileItemPrFileContentScope(
{ provider: 'github', source: { type: 'pr', repoId: 'repo-1', number: 10 } },
{ provider: 'github', headSha: 'head-a', baseSha: 'base-a' }
)
).toBe(scopeA)
expect(
createMobileProjectPrFileContentScope(
{ itemType: 'PULL_REQUEST', content: { number: 10 } },
{ id: 'repo-1' },
{ provider: 'github', headSha: 'head-a' }
)
).toBeNull()
expect(
createMobileProjectPrFileContentScope(
{ itemType: 'PULL_REQUEST', content: { number: 10 } },
{ id: 'repo-1' },
{ provider: 'github', headSha: 'head-a', baseSha: 'base-a' },
{ host: 'github.example', owner: 'orca', repo: 'app' }
)
).toBe(
createMobilePrFileContentScope({
source: 'project',
repoId: 'repo-1',
prNumber: 10,
headSha: 'head-a',
baseSha: 'base-a',
repository: { host: 'github.example', owner: 'orca', repo: 'app' }
})
)
})
it('uses the desktop diff budget as a hard byte cap with a smaller mobile entry cap', () => {
expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES).toBeLessThan(64)
expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES).toBe(24_000_000)
})
it('evicts least-recently-used files at the entry limit', () => {
const cache = new MobilePrFileContentCache(2, 100)
const a = createMobilePrFileContentKey({ path: 'a.ts' })
const b = createMobilePrFileContentKey({ path: 'b.ts' })
const c = createMobilePrFileContentKey({ path: 'c.ts' })
cache.commitRequest(cache.beginRequest(scopeA, a), contents('a'))
cache.commitRequest(cache.beginRequest(scopeA, b), contents('b'))
expect(cache.select(scopeA, a).contents).toEqual(contents('a'))
cache.commitRequest(cache.beginRequest(scopeA, c), contents('c'))
expect(cache.evidence()).toMatchObject({ entryCount: 2, keysOldestFirst: [a, c] })
expect(cache.select(scopeA, b).contents).toBeUndefined()
})
it('evicts older payloads to stay within the byte budget', () => {
const cache = new MobilePrFileContentCache(10, 8)
const a = createMobilePrFileContentKey({ path: 'a.ts' })
const b = createMobilePrFileContentKey({ path: 'b.ts' })
cache.commitRequest(cache.beginRequest(scopeA, a), contents('12345'))
cache.commitRequest(cache.beginRequest(scopeA, b), contents('67890'))
expect(cache.evidence()).toMatchObject({
entryCount: 1,
retainedBytes: 5,
keysOldestFirst: [b]
})
})
it('measures retained UTF-8 bytes rather than JavaScript code units', () => {
expect(getMobilePrFileContentByteCount(contents('a😀', 'é'))).toBe(7)
})
it('counts the retained side when the other side is an oversized sentinel', () => {
expect(
getMobilePrFileContentByteCount({
...contents('', 'retained'),
originalTooLarge: true
})
).toBe(8)
})
it('rejects a response from a prior PR context after scope replacement', () => {
const cache = new MobilePrFileContentCache(2, 100)
const key = createMobilePrFileContentKey({ path: 'file.ts' })
const oldRequest = cache.beginRequest(scopeA, key)
cache.activateScope(scopeB)
expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
expect(cache.evidence()).toMatchObject({ scope: scopeB, entryCount: 0, retainedBytes: 0 })
})
it('rejects an older request after the active file selection changes', () => {
const cache = new MobilePrFileContentCache(2, 100)
const a = createMobilePrFileContentKey({ path: 'a.ts' })
const b = createMobilePrFileContentKey({ path: 'b.ts' })
const oldRequest = cache.beginRequest(scopeA, a)
cache.select(scopeA, b)
const currentRequest = cache.beginRequest(scopeA, b)
expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
expect(cache.commitRequest(currentRequest, contents('current'))).toBe('stored')
})
it('publishes only the active scope and refuses a single over-budget payload', () => {
const cache = new MobilePrFileContentCache(2, 4)
const key = createMobilePrFileContentKey({ path: 'file.ts' })
expect(cache.commitRequest(cache.beginRequest(scopeA, key), contents('12345'))).toBe(
'too-large'
)
expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeA)).toEqual({})
expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeB)).toEqual({})
})
})
@@ -0,0 +1,226 @@
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../src/shared/large-diff-render-limit'
import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES = 8
export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4
export type MobilePrFileContentScopeInput = {
source: 'item' | 'project'
repoId: string
prNumber: number
headSha: string
baseSha: string
repository?: GitHubRepositoryIdentity | null
}
export type MobilePrFileContentKeyInput = {
path: string
oldPath?: string
status?: string
}
export type MobilePrFileContentRequestToken = {
scope: string
key: string
requestId: number
}
export type MobilePrFileContentCacheSnapshot = {
scope: string | null
contentsByKey: Readonly<Record<string, GitHubPRFileContents | undefined>>
}
export type MobilePrFileContentCacheEvidence = {
scope: string | null
entryCount: number
retainedBytes: number
keysOldestFirst: string[]
}
type CacheEntry = {
contents: GitHubPRFileContents
byteCount: number
}
type CacheSelection = {
contents: GitHubPRFileContents | undefined
scopeChanged: boolean
}
export class MobilePrFileContentCache {
private readonly entries = new Map<string, CacheEntry>()
private scope: string | null = null
private retainedBytes = 0
private requestSequence = 0
private activeRequest: MobilePrFileContentRequestToken | null = null
constructor(
private readonly maxEntries = MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
private readonly maxBytes = MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES
) {
if (
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isFinite(maxBytes) ||
maxBytes < 1
) {
throw new Error('Mobile PR file-content cache limits must be positive')
}
}
activateScope(scope: string): boolean {
if (this.scope === scope) {
return false
}
this.scope = scope
this.entries.clear()
this.retainedBytes = 0
this.activeRequest = null
return true
}
clear(): void {
this.scope = null
this.entries.clear()
this.retainedBytes = 0
this.activeRequest = null
}
select(scope: string, key: string): CacheSelection {
const scopeChanged = this.activateScope(scope)
this.activeRequest = null
const entry = this.entries.get(key)
if (!entry) {
return { contents: undefined, scopeChanged }
}
this.entries.delete(key)
this.entries.set(key, entry)
return { contents: entry.contents, scopeChanged }
}
beginRequest(scope: string, key: string): MobilePrFileContentRequestToken {
this.activateScope(scope)
const token = { scope, key, requestId: ++this.requestSequence }
this.activeRequest = token
return token
}
commitRequest(
token: MobilePrFileContentRequestToken,
contents: GitHubPRFileContents
): 'stored' | 'stale' | 'too-large' {
if (!this.isCurrentRequest(token)) {
return 'stale'
}
this.activeRequest = null
const byteCount = getMobilePrFileContentByteCount(contents)
if (byteCount > this.maxBytes) {
return 'too-large'
}
const previous = this.entries.get(token.key)
this.retainedBytes -= previous?.byteCount ?? 0
this.entries.delete(token.key)
this.entries.set(token.key, { contents, byteCount })
this.retainedBytes += byteCount
this.evictOverflow()
return 'stored'
}
rejectRequest(token: MobilePrFileContentRequestToken): boolean {
if (!this.isCurrentRequest(token)) {
return false
}
this.activeRequest = null
return true
}
snapshot(): MobilePrFileContentCacheSnapshot {
return {
scope: this.scope,
contentsByKey: Object.fromEntries(
[...this.entries].map(([key, entry]) => [key, entry.contents])
)
}
}
evidence(): MobilePrFileContentCacheEvidence {
return {
scope: this.scope,
entryCount: this.entries.size,
retainedBytes: this.retainedBytes,
keysOldestFirst: [...this.entries.keys()]
}
}
private isCurrentRequest(token: MobilePrFileContentRequestToken): boolean {
return (
this.scope === token.scope &&
this.activeRequest?.requestId === token.requestId &&
this.activeRequest.scope === token.scope &&
this.activeRequest.key === token.key
)
}
private evictOverflow(): void {
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxBytes) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
const oldest = this.entries.get(oldestKey)
this.retainedBytes -= oldest?.byteCount ?? 0
this.entries.delete(oldestKey)
}
}
}
export function createMobilePrFileContentScope(input: MobilePrFileContentScopeInput): string {
return JSON.stringify([
input.source,
input.repoId,
input.prNumber,
input.repository?.host?.toLowerCase() ?? '',
input.repository?.owner.toLowerCase() ?? '',
input.repository?.repo.toLowerCase() ?? '',
input.headSha,
input.baseSha
])
}
export function createMobilePrFileContentKey(input: MobilePrFileContentKeyInput): string {
return input.path
}
export function getMobilePrFileContentsForScope(
snapshot: MobilePrFileContentCacheSnapshot,
scope: string | null
): Readonly<Record<string, GitHubPRFileContents | undefined>> {
return scope !== null && snapshot.scope === scope ? snapshot.contentsByKey : {}
}
export function getMobilePrFileContentByteCount(contents: GitHubPRFileContents): number {
return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified)
}
function getUtf8ByteCount(value: string): number {
let byteCount = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 0x80) {
byteCount += 1
} else if (code < 0x800) {
byteCount += 2
} else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
byteCount += 4
index += 1
} else {
byteCount += 3
}
} else {
byteCount += 3
}
}
return byteCount
}
@@ -0,0 +1,183 @@
import { useCallback, useEffect, useState } from 'react'
import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
import {
MobilePrFileContentCache,
createMobilePrFileContentKey,
createMobilePrFileContentScope,
getMobilePrFileContentsForScope,
type MobilePrFileContentKeyInput
} from './mobile-pr-file-content-cache'
type MobilePrScopeTaskItem = {
provider: string
source: unknown
} | null
type MobilePrScopeDetail = {
provider: string
headSha?: unknown
baseSha?: unknown
} | null
type MobileProjectPrScopeItem = {
itemType: string
content: { number?: unknown }
} | null
type MobileProjectPrScopeRepo = { id?: unknown } | null
type MobilePrFileContentLoad = () => Promise<unknown>
type MobilePrFileContentErrorSetter = (message: string) => void
export function createMobileItemPrFileContentScope(
item: MobilePrScopeTaskItem,
detail: MobilePrScopeDetail
): string | null {
const source =
item?.source && typeof item.source === 'object'
? (item.source as { type?: unknown; repoId?: unknown; number?: unknown })
: null
if (
item?.provider !== 'github' ||
source?.type !== 'pr' ||
typeof source.repoId !== 'string' ||
typeof source.number !== 'number' ||
detail?.provider !== 'github' ||
typeof detail.headSha !== 'string' ||
!detail.headSha ||
typeof detail.baseSha !== 'string' ||
!detail.baseSha
) {
return null
}
return createMobilePrFileContentScope({
source: 'item',
repoId: source.repoId,
prNumber: source.number,
headSha: detail.headSha,
baseSha: detail.baseSha
})
}
export function createMobileProjectPrFileContentScope(
item: MobileProjectPrScopeItem,
repo: MobileProjectPrScopeRepo,
detail: MobilePrScopeDetail,
repository?: GitHubRepositoryIdentity | null
): string | null {
if (
item?.itemType !== 'PULL_REQUEST' ||
typeof item.content.number !== 'number' ||
typeof repo?.id !== 'string' ||
detail?.provider !== 'github' ||
typeof detail.headSha !== 'string' ||
!detail.headSha ||
typeof detail.baseSha !== 'string' ||
!detail.baseSha
) {
return null
}
return createMobilePrFileContentScope({
source: 'project',
repoId: repo.id,
prNumber: item.content.number,
repository,
headSha: detail.headSha,
baseSha: detail.baseSha
})
}
export function useMobilePrFileContentCache(activeScope: string | null): {
clear: () => void
contents: Readonly<Record<string, GitHubPRFileContents | undefined>>
load: (
scope: string,
file: MobilePrFileContentKeyInput,
loadContents: MobilePrFileContentLoad,
setError: MobilePrFileContentErrorSetter
) => Promise<void>
loadingPath: string | null
} {
const [cache] = useState(() => new MobilePrFileContentCache())
const [snapshot, setSnapshot] = useState(() => cache.snapshot())
const [loadingPath, setLoadingPath] = useState<string | null>(null)
const clear = useCallback(() => {
cache.clear()
setSnapshot(cache.snapshot())
setLoadingPath(null)
}, [cache])
useEffect(() => {
if (activeScope === null) {
clear()
} else if (cache.activateScope(activeScope)) {
setSnapshot(cache.snapshot())
setLoadingPath(null)
}
}, [activeScope, cache, clear])
const load = useCallback(
async (
scope: string,
file: MobilePrFileContentKeyInput,
loadContents: MobilePrFileContentLoad,
setError: MobilePrFileContentErrorSetter
): Promise<void> => {
const key = createMobilePrFileContentKey(file)
const selection = cache.select(scope, key)
if (selection.scopeChanged) {
setSnapshot(cache.snapshot())
}
if (selection.contents) {
setLoadingPath(null)
return
}
const token = cache.beginRequest(scope, key)
setLoadingPath(file.path)
setError('')
try {
const result = await loadContents()
if (!isGitHubPrFileContents(result)) {
throw new Error('Invalid file contents response')
}
const commit = cache.commitRequest(token, result)
if (commit === 'stale') {
return
}
if (commit === 'too-large') {
setError('File too large for mobile preview.')
} else {
setSnapshot(cache.snapshot())
}
setLoadingPath((current) => (current === file.path ? null : current))
} catch (error) {
if (!cache.rejectRequest(token)) {
return
}
setError(error instanceof Error ? error.message : 'Failed to load file contents')
setLoadingPath((current) => (current === file.path ? null : current))
}
},
[cache]
)
return {
clear,
contents: getMobilePrFileContentsForScope(snapshot, activeScope),
load,
loadingPath
}
}
function isGitHubPrFileContents(value: unknown): value is GitHubPRFileContents {
if (!value || typeof value !== 'object') {
return false
}
const contents = value as Partial<GitHubPRFileContents>
return (
typeof contents.original === 'string' &&
typeof contents.modified === 'string' &&
typeof contents.originalIsBinary === 'boolean' &&
typeof contents.modifiedIsBinary === 'boolean'
)
}
@@ -0,0 +1,27 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
export const DEFAULT_TERMINAL_WEBVIEW_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: colors.terminalBg,
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
}
+37 -44
View File
@@ -1,7 +1,7 @@
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
// xterm.js WebView document; extracted from TerminalWebView.tsx for the max-lines budget.
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { DEFAULT_TERMINAL_WEBVIEW_THEME } from './terminal-webview-default-theme'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
@@ -12,30 +12,9 @@ import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: colors.terminalBg,
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
}
export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000
export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096
export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `<!DOCTYPE html>
@@ -213,10 +192,11 @@ window.onerror = function(msg) {
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
var writeQueue = [];
var writeQueue = [], writeQueueUnits = 0;
var writeQueueHead = 0;
var writesDraining = false;
var afterDrainCallbacks = [];
var writesDraining = false, writeBacklogFailed = false, afterDrainCallbacks = [];
var WRITE_QUEUE_MAX_UNITS = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS}, WRITE_QUEUE_MAX_ENTRIES = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES};
var AFTER_DRAIN_MAX_CALLBACKS = ${TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS};
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
@@ -287,7 +267,7 @@ window.onerror = function(msg) {
var normalScrollFrameId = null;
var initRows = 24;
var terminalGeneration = 0;
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_WEBVIEW_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
var terminalMinimumContrastRatio = 3;
@@ -557,9 +537,22 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
}
function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
function resetWriteQueue() { writeQueue = []; writeQueueHead = 0; writeQueueUnits = 0; }
function failWriteBacklog() {
if (writeBacklogFailed) return;
writeBacklogFailed = true; terminalGeneration++; ready = false; writesDraining = false;
resetWriteQueue(); afterDrainCallbacks = [];
reportEngineError('terminal write backlog exceeded safe limit', null, true);
}
function reserveWriteQueueEntry(units) {
if (writeBacklogFailed) return false;
var pendingEntries = writeQueue.length - writeQueueHead;
if (pendingEntries >= WRITE_QUEUE_MAX_ENTRIES || writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS) {
failWriteBacklog(); return false;
}
writeQueueUnits += units; return true;
}
function isStatusDotPresentationSelector(value) {
@@ -590,13 +583,12 @@ ${TERMINAL_WEBVIEW_THEME_JS}
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
function enqueueWrite(data) { var normalized = normalizeStatusDotPresentation(data);
if (!reserveWriteQueueEntry(normalized.length)) return false;
writeQueue.push(normalized); return true;
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function enqueueWriteBoundary(callback) { if (!reserveWriteQueueEntry(0)) return false; writeQueue.push(callback); return true; }
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
@@ -605,6 +597,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
if (typeof next === 'string') writeQueueUnits = Math.max(0, writeQueueUnits - next.length);
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
@@ -660,8 +653,8 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
if (afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS) { failWriteBacklog(); return; }
afterDrainCallbacks.push(callback); pumpWrites(terminalGeneration);
}
${TERMINAL_WEBGL_RECOVERY_JS}
@@ -682,6 +675,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
webglAddon = null;
ready = false;
resetWriteQueue();
writeBacklogFailed = false;
statusDotPendingSelector = false;
writesDraining = false;
afterDrainCallbacks = [];
@@ -726,9 +720,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
disableStdin: false,
cursorBlink: false,
cursorStyle: 'bar',
// Why: native TextInput owns mobile keyboard focus, so xterm stays inactive.
// Match its active bar while still honoring application cursor-hide sequences.
cursorInactiveStyle: 'bar',
cursorInactiveStyle: 'none',
convertEol: false,
allowProposedApi: true
});
@@ -770,7 +762,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
function write(data) {
updateMouseModeFromData(data);
enqueueWrite(data);
if (!enqueueWrite(data)) return;
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
@@ -934,6 +926,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
writeBacklogFailed = false;
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
@@ -15,15 +15,6 @@ function bodyMarkup(): string {
}
type TerminalStub = ReturnType<typeof makeTerminal>
type TerminalOptions = {
cursorInactiveStyle?: string
cursorStyle?: string
}
type RegisteredWindowListener = {
listener: EventListenerOrEventListenerObject
options?: boolean | AddEventListenerOptions
type: string
}
function makeTerminal(writeCallbacks: Array<() => void>) {
const terminal = {
@@ -88,26 +79,13 @@ function dispatchInit(cols: number, initialData: string): void {
describe('terminal WebView init surface replacement', () => {
let animationFrames: Array<() => void>
let registeredWindowListeners: RegisteredWindowListener[]
let terminalOptions: TerminalOptions[]
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
beforeEach(() => {
animationFrames = []
registeredWindowListeners = []
terminalOptions = []
terminals = []
writeCallbacks = []
const addWindowEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation(((
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) => {
registeredWindowListeners.push({ type, listener, options })
addWindowEventListener(type, listener, options)
}) as typeof window.addEventListener)
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
@@ -115,42 +93,20 @@ describe('terminal WebView init surface replacement', () => {
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
const webWindow = window as unknown as {
Terminal: new (options: TerminalOptions) => TerminalStub
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function (options: TerminalOptions) {
terminalOptions.push(options)
webWindow.Terminal = function () {
const terminal = makeTerminal(writeCallbacks)
terminals.push(terminal)
return terminal
} as unknown as new (options: TerminalOptions) => TerminalStub
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage: vi.fn() }
document.body.innerHTML = bodyMarkup()
// eslint-disable-next-line no-new-func
new Function(iifeSource())()
})
afterEach(() => {
for (const { type, listener, options } of registeredWindowListeners) {
window.removeEventListener(type, listener as EventListener, options)
}
vi.restoreAllMocks()
})
it("keeps xterm's inactive cursor visible across replacement surfaces", () => {
dispatchInit(120, 'desktop')
dispatchInit(51, 'phone-resize')
dispatchInit(51, 'phone-scrollback')
expect(terminalOptions).toHaveLength(3)
for (const options of terminalOptions) {
expect(options).toMatchObject({
cursorStyle: 'bar',
cursorInactiveStyle: 'bar'
})
}
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
@@ -91,7 +91,8 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).toContain(
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
)
expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
expect(terminalHtmlSource).toContain('var normalized = normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain('writeQueue.push(normalized)')
})
it('normalizes Claude status dots idempotently across write chunks', () => {
@@ -0,0 +1,46 @@
import { once } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader,
PROCESS_LINE_MAX_BYTES
} from '../../scripts/bounded-process-line-reader.mjs'
describe('attachBoundedProcessLineReader', () => {
it('preserves ordinary LF, CRLF, and split UTF-8 lines', () => {
const stream = new PassThrough()
const lines: string[] = []
attachBoundedProcessLineReader(stream, (line) => lines.push(line))
const unicode = Buffer.from('café\n')
stream.write('first\r\nsecond\r')
stream.write('\n')
stream.write(unicode.subarray(0, -2))
stream.end(unicode.subarray(-2))
expect(lines).toEqual(['first', 'second', 'café'])
})
it('retains only the configured prefix of an unterminated line', async () => {
const stream = new PassThrough()
const lines: string[] = []
attachBoundedProcessLineReader(stream, (line) => lines.push(line), 4)
const ended = once(stream, 'end')
stream.end('abcdefgh')
await ended
expect(lines).toEqual(['abcd… [line truncated]'])
})
it('uses a bounded default for process output lines', () => {
expect(PROCESS_LINE_MAX_BYTES).toBe(64 * 1024)
})
it('keeps exact output below the tail cap and newest output above it', () => {
expect(appendProcessOutputTail('first\n', 'second', 32)).toBe('first\nsecond\n')
expect(appendProcessOutputTail('first\n', 'second', 8)).toBe('\nsecond\n')
expect(appendProcessOutputTail('first\n', 'second', 0)).toBe('')
})
})
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { responseBodyIncludesWithinLimit } from '../../scripts/bounded-response-body.mjs'
function responseWithChunks(chunks: string[], contentLength?: number): Response {
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
}),
{ headers: contentLength === undefined ? {} : { 'content-length': String(contentLength) } }
)
}
describe('responseBodyIncludesWithinLimit', () => {
it('matches a marker split across admitted chunks', async () => {
const response = responseWithChunks(['packager-', 'status:running'])
await expect(
responseBodyIncludesWithinLimit(response, 'packager-status:running', 64)
).resolves.toBe(true)
})
it('rejects streamed and declared bodies above the byte limit', async () => {
const streamed = responseWithChunks(['1234', '5'])
const declared = responseWithChunks(['ok'], 5)
await expect(responseBodyIncludesWithinLimit(streamed, 'missing', 4)).resolves.toBe(false)
await expect(responseBodyIncludesWithinLimit(declared, 'ok', 4)).resolves.toBe(false)
})
})
@@ -1,3 +1,5 @@
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
const BROWSER_SCREENCAST_KIND = 0x62
const BROWSER_SCREENCAST_VERSION = 1
const HEADER_BYTES = 16
@@ -51,7 +53,7 @@ function byteToFormat(value: number): BrowserScreencastFormat | null {
function decodeJson(bytes: Uint8Array): unknown {
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
return parseMobileJsonTextWithinLimits(new TextDecoder().decode(bytes))
} catch {
return null
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createConnectionLogStore } from './connection-log-buffer'
import { CONNECTION_LOG_HOST_ID_MAX_BYTES, createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
function entry(id: number): ConnectionLogEntry {
@@ -53,4 +53,81 @@ describe('connection log buffer', () => {
store.append('host-a', entry(2))
expect(onA).toHaveBeenCalledTimes(1)
})
it('evicts the least-recently-written inactive host past the host cap', () => {
const store = createConnectionLogStore(3, 2)
store.append('host-a', entry(1))
store.append('host-b', entry(2))
store.append('host-a', entry(3))
store.append('host-c', entry(4))
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-3'])
expect(store.get('host-b')).toEqual([])
expect(store.get('host-c').map((e) => e.id)).toEqual(['log-4'])
})
it('drops removed-host entries and refreshes an active snapshot', () => {
const store = createConnectionLogStore()
const listener = vi.fn()
store.append('host-a', entry(1))
store.subscribe('host-a', listener)
store.delete('host-a')
expect(store.get('host-a')).toEqual([])
expect(listener).toHaveBeenCalledOnce()
})
it('accepts the exact host-id limit and rejects one more byte', () => {
const store = createConnectionLogStore()
const exactHost = 'h'.repeat(CONNECTION_LOG_HOST_ID_MAX_BYTES)
store.append(exactHost, entry(1))
store.append(`${exactHost}h`, entry(2))
expect(store.get(exactHost).map((value) => value.id)).toEqual(['log-1'])
expect(store.get(`${exactHost}h`)).toEqual([])
})
it('accepts an exact entry-byte budget and rejects one more byte', () => {
const store = createConnectionLogStore(10, 10, {
maxEntryBytes: 260,
maxHostBytes: 1024,
maxStoreBytes: 2048
})
const exact: ConnectionLogEntry = { id: '', ts: 1, level: 'info', message: '' }
store.append('host-a', exact)
store.append('host-a', { ...exact, message: 'x' })
expect(store.get('host-a')).toEqual([exact])
})
it('keeps newest entries within the per-host byte budget', () => {
const store = createConnectionLogStore(10, 10, {
maxEntryBytes: 1024,
maxHostBytes: 700,
maxStoreBytes: 4096
})
store.append('host-a', entry(1))
store.append('host-a', entry(2))
store.append('host-a', entry(3))
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-2', 'log-3'])
})
it('evicts inactive hosts to stay within the aggregate byte budget', () => {
const store = createConnectionLogStore(10, 10, {
maxEntryBytes: 1024,
maxHostBytes: 1024,
maxStoreBytes: 700
})
store.append('host-a', entry(1))
store.append('host-b', entry(2))
expect(store.get('host-a')).toEqual([])
expect(store.get('host-b').map((value) => value.id)).toEqual(['log-2'])
})
})
+156 -12
View File
@@ -1,4 +1,5 @@
import type { ConnectionLogEntry } from './types'
import { measureUtf8ByteLength } from '../../../src/shared/memory-safety/utf8-byte-limits'
// Why: the rpc-client's onLog entries were only wired during pairing; for
// long-lived host connections everything went to console.log, invisible to
@@ -8,35 +9,159 @@ import type { ConnectionLogEntry } from './types'
// swaps (forceReconnect) and provider remounts (hot reload); bounded so an
// all-night reconnect loop can't grow memory unbounded.
const MAX_ENTRIES_PER_HOST = 200
const MAX_RETAINED_HOSTS = 128
export const CONNECTION_LOG_HOST_ID_MAX_BYTES = 4 * 1024
export const CONNECTION_LOG_ENTRY_MAX_BYTES = 64 * 1024
export const CONNECTION_LOG_HOST_MAX_RETAINED_BYTES = 256 * 1024
export const CONNECTION_LOG_STORE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
const CONNECTION_LOG_MAX_LISTENERS_PER_HOST = 16
type RetainedConnectionLogEntry = {
entry: ConnectionLogEntry
bytes: number
}
type RetainedHostLog = {
entries: RetainedConnectionLogEntry[]
bytes: number
}
type ConnectionLogByteLimits = {
maxEntryBytes: number
maxHostBytes: number
maxStoreBytes: number
}
function measureBoundedString(value: unknown, maxBytes: number): number | null {
if (value === undefined) {
return 0
}
if (typeof value !== 'string') {
return null
}
const measurement = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes })
return measurement.exceededLimit ? null : measurement.byteLength
}
function measureConnectionLogEntry(entry: ConnectionLogEntry, maxBytes: number): number | null {
let bytes = 256
for (const value of [entry.id, entry.level, entry.message, entry.detail]) {
const valueBytes = measureBoundedString(value, maxBytes - bytes)
if (valueBytes === null) {
return null
}
bytes += valueBytes
}
return bytes <= maxBytes ? bytes : null
}
export type ConnectionLogStore = {
append: (hostId: string, entry: ConnectionLogEntry) => void
get: (hostId: string) => readonly ConnectionLogEntry[]
subscribe: (hostId: string, listener: () => void) => () => void
delete: (hostId: string) => void
}
export function createConnectionLogStore(
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
maxRetainedHosts: number = MAX_RETAINED_HOSTS,
byteLimits: Partial<ConnectionLogByteLimits> = {}
): ConnectionLogStore {
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
const maxEntryBytes = Math.min(
byteLimits.maxEntryBytes ?? CONNECTION_LOG_ENTRY_MAX_BYTES,
CONNECTION_LOG_ENTRY_MAX_BYTES
)
const maxHostBytes = Math.min(
byteLimits.maxHostBytes ?? CONNECTION_LOG_HOST_MAX_RETAINED_BYTES,
CONNECTION_LOG_HOST_MAX_RETAINED_BYTES
)
const maxStoreBytes = Math.min(
byteLimits.maxStoreBytes ?? CONNECTION_LOG_STORE_MAX_RETAINED_BYTES,
CONNECTION_LOG_STORE_MAX_RETAINED_BYTES
)
const entriesByHost = new Map<string, RetainedHostLog>()
const listenersByHost = new Map<string, Set<() => void>>()
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
// must return the SAME array until the data actually changes, or React
// loops re-rendering. Cache per host; invalidate on append.
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
const EMPTY: readonly ConnectionLogEntry[] = []
let retainedStoreBytes = 0
const deleteHost = (hostId: string): boolean => {
const host = entriesByHost.get(hostId)
if (!host) {
return false
}
entriesByHost.delete(hostId)
retainedStoreBytes -= host.bytes
snapshotByHost.delete(hostId)
return true
}
const evictOldestUnobservedHost = (exceptHostId?: string): boolean => {
for (const hostId of entriesByHost.keys()) {
if (hostId !== exceptHostId && !listenersByHost.has(hostId)) {
return deleteHost(hostId)
}
}
return false
}
return {
append(hostId, entry) {
let entries = entriesByHost.get(hostId)
if (!entries) {
entries = []
entriesByHost.set(hostId, entries)
const hostIdBytes = measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES)
const entryBytes = measureConnectionLogEntry(entry, maxEntryBytes)
if (hostIdBytes === null || entryBytes === null || entryBytes > maxHostBytes) {
return
}
entries.push(entry)
if (entries.length > maxEntriesPerHost) {
entries.splice(0, entries.length - maxEntriesPerHost)
let host = entriesByHost.get(hostId)
if (!host) {
while (entriesByHost.size >= maxRetainedHosts) {
if (!evictOldestUnobservedHost()) {
return
}
}
const retainedHostKeyBytes = hostIdBytes + 128
while (retainedStoreBytes + retainedHostKeyBytes + entryBytes > maxStoreBytes) {
if (!evictOldestUnobservedHost()) {
return
}
}
host = { entries: [], bytes: retainedHostKeyBytes }
entriesByHost.set(hostId, host)
retainedStoreBytes += retainedHostKeyBytes
} else {
entriesByHost.delete(hostId)
entriesByHost.set(hostId, host)
}
while (
host.entries.length >= maxEntriesPerHost ||
host.bytes + entryBytes > maxHostBytes ||
retainedStoreBytes + entryBytes > maxStoreBytes
) {
const oldest = host.entries.shift()
if (oldest) {
host.bytes -= oldest.bytes
retainedStoreBytes -= oldest.bytes
continue
}
if (!evictOldestUnobservedHost(hostId)) {
break
}
}
if (
host.bytes + entryBytes > maxHostBytes ||
retainedStoreBytes + entryBytes > maxStoreBytes
) {
if (host.entries.length === 0) {
deleteHost(hostId)
}
return
}
host.entries.push({ entry, bytes: entryBytes })
host.bytes += entryBytes
retainedStoreBytes += entryBytes
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
@@ -51,21 +176,30 @@ export function createConnectionLogStore(
if (cached) {
return cached
}
const entries = entriesByHost.get(hostId)
if (!entries || entries.length === 0) {
const host = entriesByHost.get(hostId)
if (!host || host.entries.length === 0) {
return EMPTY
}
const snapshot = Object.freeze([...entries])
const snapshot = Object.freeze(host.entries.map((retained) => retained.entry))
snapshotByHost.set(hostId, snapshot)
return snapshot
},
subscribe(hostId, listener) {
if (measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) === null) {
return () => {}
}
let listeners = listenersByHost.get(hostId)
if (!listeners) {
if (listenersByHost.size >= maxRetainedHosts) {
return () => {}
}
listeners = new Set()
listenersByHost.set(hostId, listeners)
}
if (listeners.size >= CONNECTION_LOG_MAX_LISTENERS_PER_HOST) {
return () => {}
}
listeners.add(listener)
return () => {
const set = listenersByHost.get(hostId)
@@ -77,6 +211,16 @@ export function createConnectionLogStore(
listenersByHost.delete(hostId)
}
}
},
delete(hostId) {
deleteHost(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
for (const listener of listeners) {
listener()
}
}
}
}
}
+16 -3
View File
@@ -4,6 +4,10 @@
// stream frames use the raw byte bundle.
import nacl from 'tweetnacl'
import * as ExpoCrypto from 'expo-crypto'
import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
export const MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS =
Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4
// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues,
// which tweetnacl requires. expo-crypto provides a native secure RNG
@@ -39,8 +43,14 @@ function uint8ToBase64(bytes: Uint8Array): string {
return btoa(binary)
}
function base64ToUint8(b64: string): Uint8Array {
function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array {
if (b64.length > Math.ceil(maxDecodedBytes / 3) * 4) {
throw new Error('Base64 payload exceeds safe size')
}
const binary = atob(b64)
if (binary.length > maxDecodedBytes) {
throw new Error('Base64 payload exceeds safe size')
}
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
@@ -49,7 +59,7 @@ function base64ToUint8(b64: string): Uint8Array {
}
export function publicKeyFromBase64(b64: string): Uint8Array {
const key = base64ToUint8(b64)
const key = base64ToUint8(b64, 32)
if (key.length !== 32) {
throw new Error(
`Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."`
@@ -68,7 +78,10 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
}
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
const bundle = base64ToUint8(encrypted)
if (encrypted.length > MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS) {
throw new Error('Base64 payload exceeds safe size')
}
const bundle = base64ToUint8(encrypted, MOBILE_INBOUND_MAX_FRAME_BYTES)
const plaintext = decryptBytes(bundle, sharedKey)
return plaintext ? new TextDecoder().decode(plaintext) : null
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { HostClientOpenRegistry } from './host-client-open-registry'
describe('HostClientOpenRegistry', () => {
it('cancels replaced and oldest tickets while bounding pending hosts', () => {
const registry = new HostClientOpenRegistry(2)
const first = registry.register('host-a', new Promise<void>(() => {}))
const replacement = registry.register('host-a', new Promise<void>(() => {}))
const second = registry.register('host-b', new Promise<void>(() => {}))
const third = registry.register('host-c', new Promise<void>(() => {}))
expect(first.cancelled).toBe(true)
expect(replacement.cancelled).toBe(true)
expect(second.cancelled).toBe(false)
expect(third.cancelled).toBe(false)
expect(registry.getActivePromise('host-a')).toBeNull()
expect(registry.getActivePromise('host-b')).toBe(second.promise)
expect(registry.getActivePromise('host-c')).toBe(third.promise)
})
})
@@ -3,15 +3,35 @@ export type HostClientOpenTicket = {
promise: Promise<void>
}
export const HOST_CLIENT_OPEN_MAX_PENDING = 64
export class HostClientOpenRegistry {
private readonly pending = new Map<string, HostClientOpenTicket>()
constructor(private readonly maxPending = HOST_CLIENT_OPEN_MAX_PENDING) {}
getActivePromise(hostId: string): Promise<void> | null {
const ticket = this.pending.get(hostId)
return ticket && !ticket.cancelled ? ticket.promise : null
}
register(hostId: string, promise: Promise<void>): HostClientOpenTicket {
const prior = this.pending.get(hostId)
if (prior) {
prior.cancelled = true
this.pending.delete(hostId)
}
while (this.pending.size >= Math.max(1, this.maxPending)) {
const oldestHostId = this.pending.keys().next().value as string | undefined
if (oldestHostId === undefined) {
break
}
const oldest = this.pending.get(oldestHostId)
if (oldest) {
oldest.cancelled = true
}
this.pending.delete(oldestHostId)
}
const ticket = { cancelled: false, promise }
this.pending.set(hostId, ticket)
return ticket
@@ -1,6 +1,8 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES,
HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS,
loadPendingHostCredentialCleanup,
loadPendingHostCredentialCleanupIds,
resetHostCredentialCleanupForTests,
@@ -270,4 +272,41 @@ describe('host credential cleanup', () => {
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
})
it('accepts the exact pending-id cap and treats one over as unreadable', async () => {
storedPendingIds = Array.from(
{ length: HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS },
(_, index) => `host-${index}`
)
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
ids: storedPendingIds,
storageUnreadable: false
})
storedPendingIds = [...storedPendingIds, 'one-over']
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
ids: [],
storageUnreadable: true
})
})
it('caps simultaneous native deletes while preserving excess durable intents', async () => {
vi.useFakeTimers()
const deleteCredential = vi.fn(() => new Promise<void>(() => undefined))
const hostCount = HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES + 1
await Promise.all(
Array.from({ length: hostCount }, (_, index) =>
scheduleHostCredentialCleanup(`host-${index}`, deleteCredential, 3_000)
)
)
await flushMicrotasks()
expect(deleteCredential).toHaveBeenCalledTimes(HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES)
expect(storedPendingIds).toHaveLength(hostCount)
await vi.advanceTimersByTimeAsync(3_000)
await expect(loadPendingHostCredentialCleanupIds()).resolves.toHaveLength(hostCount)
})
})
@@ -1,7 +1,12 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups'
const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000
export const HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS = 256
export const HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS = 256 * 1024
export const HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS = 4_096
export const HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES = 16
type DeleteHostCredential = (hostId: string) => Promise<void>
type CleanupAttemptResult = 'cleared' | 'pending'
@@ -33,7 +38,11 @@ function notifyPendingListeners(): void {
}
function markUnrecordedPending(hostId: string): void {
if (unrecordedPendingIds.has(hostId)) {
if (
unrecordedPendingIds.has(hostId) ||
!isTrackableHostId(hostId) ||
unrecordedPendingIds.size >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS
) {
return
}
unrecordedPendingIds.add(hostId)
@@ -47,17 +56,55 @@ function clearUnrecordedPending(hostId: string): void {
}
function parsePendingIds(raw: string): string[] | null {
if (raw.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) {
return null
}
try {
const parsed = JSON.parse(raw) as unknown
const parsed = parseMobileJsonTextWithinLimits(raw)
if (!Array.isArray(parsed)) {
return null
}
return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))]
const ids: string[] = []
const seen = new Set<string>()
for (const value of parsed) {
if (typeof value !== 'string') {
continue
}
if (!isTrackableHostId(value)) {
return null
}
if (!seen.has(value)) {
if (ids.length >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS) {
return null
}
seen.add(value)
ids.push(value)
}
}
return ids
} catch {
return null
}
}
function isTrackableHostId(hostId: string): boolean {
return hostId.length > 0 && hostId.length <= HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS
}
function serializePendingIds(ids: string[]): string {
if (
ids.length > HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS ||
ids.some((id) => !isTrackableHostId(id))
) {
throw new Error('pending host credential cleanup limit exceeded')
}
const serialized = JSON.stringify(ids)
if (serialized.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) {
throw new Error('pending host credential cleanup storage limit exceeded')
}
return serialized
}
function sameIdList(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((id, index) => id === b[index])
}
@@ -103,7 +150,7 @@ async function mutatePendingIds(update: (ids: string[]) => string[]): Promise<vo
if (sameIdList(current.ids, next)) {
return
}
await AsyncStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(next))
await AsyncStorage.setItem(PENDING_STORAGE_KEY, serializePendingIds(next))
notifyPendingListeners()
})
pendingMutation = mutation.catch(() => {})
@@ -142,6 +189,9 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
if (existing) {
return existing
}
if (inflightDeletes.size >= HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) {
return Promise.reject(new Error('host credential cleanup concurrency limit reached'))
}
const cleanup = Promise.resolve()
.then(() => deleteCredential(hostId))
.finally(() => {
@@ -154,6 +204,9 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
}
async function recordCleanupIntent(hostId: string): Promise<boolean> {
if (!isTrackableHostId(hostId)) {
return false
}
try {
await addPendingId(hostId)
return true
@@ -234,10 +287,10 @@ export async function retryPendingHostCredentialCleanups(
deleteCredential: DeleteHostCredential
): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> {
const pending = await loadPendingCleanupState()
const outcomes = await Promise.all(
// Why: these ids are already durable (or a session-scoped fallback). Re-adding
// intent can race a late success and recreate a ghost row after deletion.
pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS))
const outcomes = await retryCleanupIds(
pending.ids,
deleteCredential,
HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES
)
const remaining = await loadPendingCleanupState()
return {
@@ -247,6 +300,29 @@ export async function retryPendingHostCredentialCleanups(
}
}
async function retryCleanupIds(
ids: string[],
deleteCredential: DeleteHostCredential,
concurrency: number
): Promise<CleanupAttemptResult[]> {
const outcomes: CleanupAttemptResult[] = []
let nextIndex = 0
const worker = async (): Promise<void> => {
while (nextIndex < ids.length) {
const index = nextIndex++
// Why: these ids already have recovery intent; re-adding can race a late
// success and recreate a ghost row after deletion.
outcomes[index] = await confirmNativeCleanup(
ids[index]!,
deleteCredential,
CLEANUP_CONFIRM_TIMEOUT_MS
)
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()))
return outcomes
}
/** Test-only: drop module listeners/in-flight state between cases. */
export function resetHostCredentialCleanupForTests(): void {
inflightDeletes.clear()
@@ -1,16 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const removeHostMock = vi.hoisted(() => vi.fn())
const { removeHostMock, deleteConnectionLogMock } = vi.hoisted(() => ({
removeHostMock: vi.fn(),
deleteConnectionLogMock: vi.fn()
}))
vi.mock('./host-store', () => ({
removeHost: (hostId: string) => removeHostMock(hostId)
}))
vi.mock('./connection-log-buffer', () => ({
connectionLogStore: { delete: deleteConnectionLogMock }
}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
deleteConnectionLogMock.mockReset()
})
it('closes the client only after metadata removal commits', async () => {
@@ -28,6 +35,7 @@ describe('host removal lifecycle', () => {
await removal
expect(closeHostClient).toHaveBeenCalledWith('host-1')
expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1')
})
it('keeps the client open when metadata removal fails', async () => {
@@ -38,5 +46,18 @@ describe('host removal lifecycle', () => {
'storage unavailable'
)
expect(closeHostClient).not.toHaveBeenCalled()
expect(deleteConnectionLogMock).not.toHaveBeenCalled()
})
it('forgets removed-host logs even when client teardown throws', async () => {
removeHostMock.mockResolvedValue(undefined)
const closeHostClient = vi.fn(() => {
throw new Error('close failed')
})
await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow(
'close failed'
)
expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1')
})
})
@@ -1,4 +1,5 @@
import { removeHost } from './host-store'
import { connectionLogStore } from './connection-log-buffer'
export async function removeHostAndCloseClient(
hostId: string,
@@ -7,5 +8,9 @@ export async function removeHostAndCloseClient(
// Why: closing before the metadata commit can strand a still-paired host on
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
closeHostClient(hostId)
try {
closeHostClient(hostId)
} finally {
connectionLogStore.delete(hostId)
}
}
+108
View File
@@ -34,6 +34,8 @@ vi.mock('./host-credential-cleanup', () => ({
}))
import {
HOST_TOKEN_CACHE_MAX_ENTRIES,
HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS,
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
@@ -44,7 +46,18 @@ import {
updateHostNameAndEndpoint,
updateLastConnected
} from './host-store'
import {
STORED_HOSTS_MAX_ENTRIES,
STORED_HOSTS_MAX_STORAGE_CHARACTERS
} from './mobile-host-list-storage'
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
import {
MOBILE_HOST_ID_MAX_CHARACTERS,
MOBILE_HOST_NAME_MAX_CHARACTERS,
PAIRING_DEVICE_TOKEN_MAX_CHARACTERS,
PAIRING_ENDPOINT_MAX_CHARACTERS,
PAIRING_PUBLIC_KEY_MAX_CHARACTERS
} from './types'
const HOSTS_STORAGE_KEY = 'orca:hosts'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
@@ -222,6 +235,101 @@ describe('host-store list mutations', () => {
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
})
it('bounds cached keychain tokens without changing loaded hosts', async () => {
const storedHosts = Array.from({ length: HOST_TOKEN_CACHE_MAX_ENTRIES + 1 }, (_, index) => ({
...HOST_ONE,
id: `host-${index}`,
publicKeyB64: `key-${index}`
}))
storedHostsRaw = JSON.stringify(storedHosts)
secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`)
await expect(loadHosts()).resolves.toHaveLength(storedHosts.length)
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length)
await expect(loadHosts()).resolves.toHaveLength(storedHosts.length)
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length + 1)
})
it('does not retain an oversized token in the in-memory cache', async () => {
storedHostsRaw = JSON.stringify([HOST_ONE])
secureStoreMock.getItemAsync.mockResolvedValue(
'x'.repeat(HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS)
)
await expect(loadHosts()).resolves.toHaveLength(1)
await expect(loadHosts()).resolves.toHaveLength(1)
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(2)
})
it('accepts the exact host count in order and rejects one more', async () => {
const exact = Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({
...HOST_ONE,
id: `host-${index}`,
publicKeyB64: `key-${index}`
}))
storedHostsRaw = JSON.stringify(exact)
secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`)
await expect(loadHosts()).resolves.toMatchObject(
exact.map((host) => ({ id: host.id, publicKeyB64: host.publicKeyB64 }))
)
storedHostsRaw = JSON.stringify([
...exact,
{ ...HOST_ONE, id: 'host-overflow', publicKeyB64: 'key-overflow' }
])
await expect(loadHosts()).resolves.toEqual([])
})
it('does not parse an oversized host-list payload', async () => {
storedHostsRaw = {
length: STORED_HOSTS_MAX_STORAGE_CHARACTERS + 1
} as unknown as string
const parse = vi.spyOn(JSON, 'parse')
await expect(loadHosts()).resolves.toEqual([])
expect(parse).not.toHaveBeenCalled()
expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled()
parse.mockRestore()
})
it('accepts exact host field limits and rejects one character more', async () => {
const exact = {
id: 'i'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS),
name: 'n'.repeat(MOBILE_HOST_NAME_MAX_CHARACTERS),
endpoint: 'e'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS),
deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS),
publicKeyB64: 'p'.repeat(PAIRING_PUBLIC_KEY_MAX_CHARACTERS),
lastConnected: 1
}
await expect(saveHost(exact)).resolves.toBeUndefined()
await expect(saveHost({ ...exact, name: `${exact.name}n` })).rejects.toThrow()
})
it('refuses a write that would exceed the stored host count', async () => {
storedHostsRaw = JSON.stringify(
Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({
...HOST_ONE,
id: `host-${index}`,
publicKeyB64: `key-${index}`
}))
)
await expect(
saveHost({
...HOST_ONE,
id: 'host-overflow',
publicKeyB64: 'key-overflow',
deviceToken: 'token'
})
).rejects.toThrow(/storage limit/)
expect(asyncStorageMock.setItem).not.toHaveBeenCalledWith(HOSTS_STORAGE_KEY, expect.any(String))
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
})
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
storedHostsRaw = JSON.stringify([HOST_TWO])
+31 -27
View File
@@ -3,7 +3,7 @@ import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import {
HostProfileSchema,
StoredHostProfileSchema,
PAIRING_DEVICE_TOKEN_MAX_CHARACTERS,
type HostProfile,
type StoredHostProfile
} from './types'
@@ -21,6 +21,10 @@ import {
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
import {
parseMobileStoredHostList,
serializeMobileStoredHostList
} from './mobile-host-list-storage'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
@@ -43,10 +47,11 @@ function webTokenKey(hostId: string): string {
async function readDeviceToken(hostId: string): Promise<string | null> {
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
if (Platform.OS === 'web') {
return AsyncStorage.getItem(webTokenKey(hostId))
}
return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
const token =
Platform.OS === 'web'
? await AsyncStorage.getItem(webTokenKey(hostId))
: await SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
return token !== null && token.length <= PAIRING_DEVICE_TOKEN_MAX_CHARACTERS ? token : null
}
async function writeDeviceToken(hostId: string, token: string): Promise<void> {
@@ -72,31 +77,30 @@ async function deleteHostCredentials(hostId: string): Promise<void> {
}
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
export const HOST_TOKEN_CACHE_MAX_ENTRIES = 64
export const HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS = 64 * 1024
const tokenCache = new Map<string, string>()
let inflightLoad: Promise<HostProfile[]> | null = null
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
let hostListMutation: Promise<void> = Promise.resolve()
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
if (!raw) {
return []
function rememberHostToken(hostId: string, token: string, allowEviction: boolean): void {
if (hostId.length + token.length > HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS) {
tokenCache.delete(hostId)
return
}
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) {
return null
if (tokenCache.has(hostId)) {
tokenCache.delete(hostId)
} else if (tokenCache.size >= HOST_TOKEN_CACHE_MAX_ENTRIES) {
if (!allowEviction) {
return
}
const oldestHostId = tokenCache.keys().next().value
if (oldestHostId !== undefined) {
tokenCache.delete(oldestHostId)
}
return parsed.flatMap((item) => {
// Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
if (item && typeof item === 'object' && 'deviceToken' in item) {
return []
}
const result = StoredHostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
tokenCache.set(hostId, token)
}
export async function loadHosts(): Promise<HostProfile[]> {
@@ -114,7 +118,7 @@ export async function loadHosts(): Promise<HostProfile[]> {
async function doLoadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
const storedHosts = parseStoredHosts(raw)
const storedHosts = parseMobileStoredHostList(raw)
if (!storedHosts) {
return []
}
@@ -143,7 +147,7 @@ async function doLoadHosts(): Promise<HostProfile[]> {
continue
}
token = fetched
tokenCache.set(stored.id, token)
rememberHostToken(stored.id, token, false)
}
const overlay = overlays.get(stored.id)
out.push({
@@ -176,7 +180,7 @@ export async function resolvePairingHostIdentity(
async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
try {
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
const parsed = parseMobileStoredHostList(await AsyncStorage.getItem(STORAGE_KEY))
if (!parsed) {
// Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
throw new Error('host list storage unreadable')
@@ -196,7 +200,7 @@ async function mutateStoredHosts(
const mutation = hostListMutation.then(async () => {
const current = await readStoredHostsForMutation()
const next = update(current)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
await AsyncStorage.setItem(STORAGE_KEY, serializeMobileStoredHostList(next))
})
hostListMutation = mutation.catch(() => {})
return mutation
@@ -249,7 +253,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
})
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
rememberHostToken(stored.id, validated.deviceToken, true)
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMobileDirectRpcOutbound } from './mobile-direct-rpc-outbound'
import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget'
import { MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS } from './mobile-outbound-socket-ledger'
vi.mock('./e2ee', () => ({
encrypt: (plaintext: string) => `encrypted:${plaintext}`
}))
function socket() {
return {
OPEN: 1,
bufferedAmount: 0,
readyState: 1,
send: vi.fn()
}
}
describe('mobile direct RPC outbound queue', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('parks in FIFO order while another socket owns aggregate native buffer credit', () => {
const budget = createMobileOutboundMemoryBudget({
maxBufferedBytes: 100,
maxQueuedBytes: 1_000
})
const firstSocket = socket()
const secondSocket = socket()
firstSocket.bufferedAmount = 60
secondSocket.bufferedAmount = 41
const first = createMobileDirectRpcOutbound({
socket: firstSocket,
isActive: () => true,
onOverflow: vi.fn(),
memoryBudget: budget
})
const second = createMobileDirectRpcOutbound({
socket: secondSocket,
isActive: () => true,
onOverflow: vi.fn(),
memoryBudget: budget
})
expect(first.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true)
expect(first.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true)
expect(firstSocket.send).not.toHaveBeenCalled()
expect(budget.evidence().queuedClaimCount).toBe(2)
secondSocket.bufferedAmount = 0
firstSocket.bufferedAmount = 0
vi.advanceTimersByTime(25)
expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one'])
first.acknowledge('rpc-1')
vi.advanceTimersByTime(25)
expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual([
'encrypted:one',
'encrypted:two'
])
first.acknowledge('rpc-2')
expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 })
first.dispose()
second.dispose()
expect(budget.evidence().bufferedSourceCount).toBe(2)
first.socketClosed()
second.socketClosed()
expect(budget.evidence().bufferedSourceCount).toBe(0)
})
it('fails the offending connection when aggregate JavaScript admission is exhausted', () => {
const budget = createMobileOutboundMemoryBudget({
maxBufferedBytes: 1,
maxQueuedBytes: 10
})
const targetSocket = socket()
targetSocket.bufferedAmount = 2
const onOverflow = vi.fn()
const outbound = createMobileDirectRpcOutbound({
socket: targetSocket,
isActive: () => true,
onOverflow,
memoryBudget: budget
})
expect(outbound.enqueue('payload', new Uint8Array(32))).toBe(false)
expect(onOverflow).toHaveBeenCalledOnce()
expect(targetSocket.send).not.toHaveBeenCalled()
expect(budget.evidence()).toMatchObject({
bufferedSourceCount: 1,
queuedBytes: 0,
queuedClaimCount: 0
})
outbound.dispose()
expect(budget.evidence().bufferedSourceCount).toBe(1)
outbound.socketClosed()
expect(budget.evidence().bufferedSourceCount).toBe(0)
})
it('uses matching RPC responses as backpressure when bufferedAmount is unavailable', () => {
const budget = createMobileOutboundMemoryBudget({
maxBufferedBytes: 100,
maxQueuedBytes: 1_000
})
const targetSocket = socket()
targetSocket.bufferedAmount = Number.NaN
const outbound = createMobileDirectRpcOutbound({
socket: targetSocket,
isActive: () => true,
onOverflow: vi.fn(),
memoryBudget: budget
})
expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true)
expect(outbound.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true)
expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one'])
outbound.acknowledge('rpc-1')
vi.advanceTimersByTime(25)
expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual([
'encrypted:one',
'encrypted:two'
])
expect(budget.evidence()).toMatchObject({ inFlightBytes: 60, inFlightClaimCount: 1 })
outbound.socketClosed()
expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 })
})
it('reports synchronous native send failure instead of marking a stream as sent', () => {
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 100 })
const targetSocket = socket()
targetSocket.send.mockImplementation(() => {
throw new Error('native send failed')
})
const onOverflow = vi.fn()
const outbound = createMobileDirectRpcOutbound({
socket: targetSocket,
isActive: () => true,
onOverflow,
memoryBudget: budget
})
expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(false)
expect(onOverflow).toHaveBeenCalledOnce()
expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 })
outbound.socketClosed()
})
it('allows another socket after a retired socket never reports close', () => {
const budget = createMobileOutboundMemoryBudget({
maxBufferedBytes: 100,
maxBufferedSources: 1
})
const first = createMobileDirectRpcOutbound({
socket: socket(),
isActive: () => true,
onOverflow: vi.fn(),
memoryBudget: budget
})
first.dispose()
expect(budget.canRegisterBufferedAmount()).toBe(false)
vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS)
expect(budget.canRegisterBufferedAmount()).toBe(true)
const second = createMobileDirectRpcOutbound({
socket: socket(),
isActive: () => true,
onOverflow: vi.fn(),
memoryBudget: budget
})
second.socketClosed()
expect(budget.evidence().bufferedSourceCount).toBe(0)
})
})
@@ -0,0 +1,126 @@
import { createWsOutboundBackpressureQueue } from '../../../src/shared/ws-outbound-backpressure-queue'
import { getUtf8ByteLength } from '../../../src/shared/memory-safety/utf8-byte-limits'
import { encrypt } from './e2ee'
import {
processMobileOutboundMemoryBudget,
MOBILE_OUTBOUND_MAX_FRAME_BYTES,
type MobileOutboundMemoryBudget
} from './mobile-outbound-memory-budget'
import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger'
type DirectRpcOutboundFrame = {
acknowledgementKey?: string
key: Uint8Array
plaintext: string
}
type DirectRpcOutboundSocket = {
OPEN: number
bufferedAmount: number
readyState: number
send(frame: string): void
}
export type MobileDirectRpcOutbound = {
acknowledge(key: string): void
acknowledgeAuthentication(): void
dispose(): void
enqueue(plaintext: string, key: Uint8Array, acknowledgementKey?: string): boolean
socketClosed(): void
}
const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-direct-authentication'
export function createMobileDirectRpcOutbound(args: {
socket: DirectRpcOutboundSocket
isActive: () => boolean
onOverflow: () => void
memoryBudget?: MobileOutboundMemoryBudget
}): MobileDirectRpcOutbound {
const memoryBudget = args.memoryBudget ?? processMobileOutboundMemoryBudget
let stopped = false
let disposed = false
let overflowed = false
const socketLedger = createMobileOutboundSocketLedger({
memoryBudget,
readBufferedAmount: () => args.socket.bufferedAmount
})
const failOverflow = (): void => {
if (overflowed) {
return
}
overflowed = true
stopped = true
args.onOverflow()
}
const queue = createWsOutboundBackpressureQueue<DirectRpcOutboundFrame>({
send: (frame) => {
const bytes = encryptedTextFrameBytes(frame.plaintext)
const cancelClaim = socketLedger.claimSentBytes(bytes, frame.acknowledgementKey)
if (!cancelClaim) {
failOverflow()
return
}
try {
args.socket.send(encrypt(frame.plaintext, frame.key))
} catch {
cancelClaim()
failOverflow()
}
},
byteLengthOf: (frame) => encryptedTextFrameBytes(frame.plaintext),
getBufferedAmount: () => args.socket.bufferedAmount,
isWritable: () => args.isActive() && args.socket.readyState === args.socket.OPEN && !stopped,
canSend: (bytes) => socketLedger.canSend(bytes),
claimQueuedBytes: (bytes) => memoryBudget.claimQueuedBytes(bytes),
maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES,
onOverflow: failOverflow
})
return {
acknowledge: (key) => socketLedger.acknowledge(key),
acknowledgeAuthentication: () => socketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY),
dispose(): void {
if (disposed) {
return
}
disposed = true
stopped = true
queue.dispose()
socketLedger.retire()
},
enqueue(plaintext, key, acknowledgementKey): boolean {
if (stopped || !args.isActive() || args.socket.readyState !== args.socket.OPEN) {
return false
}
const accepted = queue.enqueue({
plaintext,
key,
acknowledgementKey:
acknowledgementKey ??
(isAuthenticationRequest(plaintext) ? AUTHENTICATION_ACKNOWLEDGEMENT_KEY : undefined)
})
return accepted && !overflowed
},
socketClosed(): void {
stopped = true
queue.dispose()
socketLedger.socketClosed()
}
}
}
function encryptedTextFrameBytes(plaintext: string): number {
const encryptedBytes = getUtf8ByteLength(plaintext) + 40
const wireBytes = Math.ceil(encryptedBytes / 3) * 4
return Math.max(wireBytes, plaintext.length * 2)
}
function isAuthenticationRequest(plaintext: string): boolean {
try {
const value = JSON.parse(plaintext) as { type?: unknown }
return value.type === 'e2ee_auth'
} catch {
return false
}
}
@@ -0,0 +1,51 @@
import type { MobileDirectRpcOutbound } from './mobile-direct-rpc-outbound'
import { stringifyMobileOutboundJson } from './mobile-outbound-json'
import type { ConnectionState } from './types'
export function createMobileDirectRpcSender(args: {
getOutbound: () => MobileDirectRpcOutbound | null
getSharedKey: () => Uint8Array | null
getSocket: () => WebSocket | null
getState: () => ConnectionState
onSocketDesync: (socket: WebSocket) => void
}): (request: unknown) => boolean {
return (request): boolean => {
const socket = args.getSocket()
const sharedKey = args.getSharedKey()
const outbound = args.getOutbound()
if (socket && socket.readyState === WebSocket.OPEN && sharedKey && outbound) {
try {
return outbound.enqueue(
stringifyMobileOutboundJson(request),
sharedKey,
requestAcknowledgementKey(request)
)
} catch (error) {
console.warn('[net] outbound request rejected', error)
return false
}
}
const state = args.getState()
console.log('[net] sendEncrypted FAILED — channel not ready', {
hasWs: !!socket,
readyState: socket?.readyState,
hasKey: !!sharedKey,
state
})
if (state === 'connected' && socket && socket.readyState !== WebSocket.OPEN) {
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: socket.readyState
})
args.onSocketDesync(socket)
}
return false
}
}
function requestAcknowledgementKey(request: unknown): string | undefined {
if (!request || typeof request !== 'object') {
return undefined
}
const id = (request as { id?: unknown }).id
return typeof id === 'string' ? id : undefined
}
@@ -6,7 +6,13 @@ vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { decrypt, decryptBytes, deriveSharedKey } from './e2ee'
import {
MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS,
decrypt,
decryptBytes,
deriveSharedKey,
publicKeyFromBase64
} from './e2ee'
describe('mobile legacy E2EE fixtures', () => {
it('matches the captured desktop key and text/binary frames', () => {
@@ -19,6 +25,17 @@ describe('mobile legacy E2EE fixtures', () => {
expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext)
expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext)
})
it('rejects oversized base64 before calling the decoder', () => {
const decode = vi.spyOn(globalThis, 'atob')
const oversizedFrame = {
length: MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS + 1
} as unknown as string
expect(() => publicKeyFromBase64('A'.repeat(45))).toThrow(/safe size/)
expect(() => decrypt(oversizedFrame, new Uint8Array(32))).toThrow(/safe size/)
expect(decode).not.toHaveBeenCalled()
})
})
function hex(bytes: Uint8Array): string {
@@ -12,8 +12,12 @@ vi.mock('expo-crypto', () => ({
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import {
MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS,
MobileE2EEV2ClientSession
} from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
@@ -82,4 +86,19 @@ describe('mobile E2EE v2 client session', () => {
expect(session.openText(encoded)).toBe('authenticated')
expect(session.openText(encoded)).toBeNull()
})
it('rejects oversized text and binary frames before materialization', () => {
const { session } = setup()
const decode = vi.spyOn(globalThis, 'atob')
const oversizedText = {
length: MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS + 1
} as unknown as string
const oversizedBinary = {
byteLength: MOBILE_INBOUND_MAX_FRAME_BYTES + 1
} as unknown as Uint8Array
expect(session.openText(oversizedText)).toBeNull()
expect(session.openBinary(oversizedBinary)).toBeNull()
expect(decode).not.toHaveBeenCalled()
})
})
@@ -11,6 +11,10 @@ import {
} from '../../../src/shared/mobile-e2ee-v2-framing'
import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
export const MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS =
Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4
export class MobileE2EEV2ClientSession {
readonly hello: MobileE2EEV2Hello
@@ -91,6 +95,9 @@ export class MobileE2EEV2ClientSession {
}
openBinary(frame: Uint8Array): Uint8Array | null {
if (frame.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) {
return null
}
return this.open(frame, 'binary')
}
@@ -146,8 +153,14 @@ function encodeBase64(bytes: Uint8Array): string {
}
function decodeCanonicalBase64(value: string): Uint8Array | null {
if (value.length > MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS) {
return null
}
try {
const binary = atob(value)
if (binary.length > MOBILE_INBOUND_MAX_FRAME_BYTES) {
return null
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return encodeBase64(bytes) === value ? bytes : null
} catch {
@@ -22,11 +22,16 @@ import {
MobileE2EEV2PhysicalChannel,
type MobileE2EEV2Socket
} from './mobile-e2ee-v2-physical-channel'
import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget'
import { MOBILE_OUTBOUND_MAX_JSON_BYTES } from './mobile-outbound-json'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
function setup(
decodeBinary: (raw: unknown) => Promise<Uint8Array | null>,
deviceToken = 'valid-token'
) {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
@@ -44,15 +49,17 @@ function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
const events: string[] = []
const onAuthenticated = vi.fn(() => events.push('authenticated'))
const onError = vi.fn()
const outboundMemoryBudget = createMobileOutboundMemoryBudget()
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
deviceToken: 'valid-token',
deviceToken,
decodeBinary,
onAuthenticated,
onText: (plaintext) => events.push(`text:${plaintext}`),
onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`),
onError
onError,
outboundMemoryBudget
})
channel.start()
@@ -72,7 +79,18 @@ function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule }
return {
channel,
session,
socket,
sent,
events,
onAuthenticated,
onError,
outboundMemoryBudget,
ready,
schedule
}
}
function serverFrame(
@@ -122,6 +140,17 @@ describe('mobile E2EE v2 physical channel', () => {
expect(ctx.onError).not.toHaveBeenCalled()
})
it('rejects ready JSON above the nesting cap before object materialization', async () => {
const ctx = setup(async () => null)
await ctx.channel.handleMessage(`${'['.repeat(129)}0${']'.repeat(129)}`)
expect(ctx.onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Invalid E2EE v2 ready JSON' })
)
expect(ctx.sent).toHaveLength(1)
})
it('classifies the encrypted desktop device-token rejection as global auth failure', async () => {
const ctx = setup(async () => null)
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
@@ -140,6 +169,18 @@ describe('mobile E2EE v2 physical channel', () => {
expect(ctx.onAuthenticated).not.toHaveBeenCalled()
})
it('rejects oversized authentication JSON before sealing or sending it', async () => {
const ctx = setup(async () => null, 'x'.repeat(MOBILE_OUTBOUND_MAX_JSON_BYTES))
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
expect(ctx.sent).toHaveLength(1)
expect(ctx.onError).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Mobile outbound JSON exceeds') })
)
ctx.channel.socketClosed()
})
it('serializes delayed binary conversion before a later text counter', async () => {
let releaseBinary!: (bytes: Uint8Array) => void
const pendingBinary = new Promise<Uint8Array>((resolve) => (releaseBinary = resolve))
@@ -160,6 +201,27 @@ describe('mobile E2EE v2 physical channel', () => {
expect(ctx.onError).not.toHaveBeenCalled()
})
it('fails closed when delayed inbound conversion lets frames exceed the backlog bound', async () => {
let releaseBinary!: (bytes: Uint8Array) => void
const pendingBinary = new Promise<Uint8Array>((resolve) => (releaseBinary = resolve))
const ctx = setup(async () => pendingBinary)
await authenticate(ctx)
const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule)
const pending = Array.from({ length: 65 }, () =>
ctx.channel.handleMessage({ delayedBlob: true })
)
await Promise.resolve()
try {
expect(ctx.onError).toHaveBeenCalledOnce()
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 inbound buffer overflow')
} finally {
ctx.channel.dispose()
releaseBinary(binary)
await Promise.all(pending)
}
})
it('queues outbound text and binary in one counter order', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
@@ -194,15 +256,61 @@ describe('mobile E2EE v2 physical channel', () => {
).toEqual(new Uint8Array([2]))
})
it('releases sent-frame reservations only after the matching response arrives', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
expect(ctx.outboundMemoryBudget.evidence().inFlightBytes).toBe(0)
expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))).toBe(true)
expect(ctx.outboundMemoryBudget.evidence().inFlightClaimCount).toBe(1)
const response = serverFrame(
new TextEncoder().encode(JSON.stringify({ id: 'rpc-1', ok: true, result: {} })),
'text',
1n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(response).toString('base64'))
expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({
inFlightBytes: 0,
inFlightClaimCount: 0
})
ctx.channel.socketClosed()
})
it('reports synchronous native send failure to relay callers', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.send = () => {
throw new Error('native send failed')
}
expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1' }))).toBe(false)
expect(ctx.onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'E2EE v2 outbound buffer overflow' })
)
expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({
inFlightBytes: 0,
inFlightClaimCount: 0
})
ctx.channel.socketClosed()
})
it('bounds the unified outbound queue and reports a wedged link', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
const megabyte = new Uint8Array(1024 * 1024)
for (let index = 0; index < 65; index++) {
expect(ctx.channel.sendBinary(megabyte)).toBe(true)
ctx.channel.sendBinary(megabyte)
}
expect(ctx.onError).toHaveBeenCalledOnce()
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow')
expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1)
ctx.channel.dispose()
expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1)
ctx.channel.socketClosed()
expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(0)
})
})
@@ -2,10 +2,28 @@ import {
createWsOutboundBackpressureQueue,
type WsOutboundBackpressureQueue
} from '../../../src/shared/ws-outbound-backpressure-queue'
import { getUtf8ByteLength } from '../../../src/shared/memory-safety/utf8-byte-limits'
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import {
createMobileInboundFrameQueue,
MOBILE_INBOUND_MAX_FRAME_BYTES,
type MobileInboundFrameQueue
} from './mobile-inbound-frame-queue'
import {
processMobileOutboundMemoryBudget,
MOBILE_OUTBOUND_MAX_FRAME_BYTES,
type MobileOutboundMemoryBudget
} from './mobile-outbound-memory-budget'
import { stringifyMobileOutboundJson } from './mobile-outbound-json'
import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger'
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array }
type OutboundItem =
| { kind: 'text'; plaintext: string; acknowledgementKey?: string }
| { kind: 'binary'; plaintext: Uint8Array }
const E2EE_V2_FRAME_OVERHEAD_BYTES = 82
const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-e2ee-v2-authentication'
export class MobileE2EEAuthenticationError extends Error {
constructor() {
@@ -23,8 +41,10 @@ export type MobileE2EEV2Socket = {
export class MobileE2EEV2PhysicalChannel {
private state: ChannelState = 'awaiting-ready'
private generation = 0
private inboundChain: Promise<void> = Promise.resolve()
private readonly inboundQueue: MobileInboundFrameQueue
private readonly outboundQueue: WsOutboundBackpressureQueue<OutboundItem>
private readonly outboundSocketLedger
private outboundOverflowed = false
constructor(
private readonly args: {
@@ -36,25 +56,50 @@ export class MobileE2EEV2PhysicalChannel {
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onError: (error: Error) => void
outboundMemoryBudget?: MobileOutboundMemoryBudget
}
) {
this.inboundQueue = createMobileInboundFrameQueue({
process: (raw) => this.processMessage(raw, this.generation),
onError: args.onError,
overflowMessage: 'E2EE v2 inbound buffer overflow',
frameTooLargeMessage: 'E2EE v2 inbound frame too large'
})
const outboundMemoryBudget = args.outboundMemoryBudget ?? processMobileOutboundMemoryBudget
this.outboundSocketLedger = createMobileOutboundSocketLedger({
memoryBudget: outboundMemoryBudget,
readBufferedAmount: () => args.socket.bufferedAmount
})
this.outboundQueue = createWsOutboundBackpressureQueue<OutboundItem>({
// Why: encryption happens only when an admitted item reaches the wire,
// so a bounded-queue rejection cannot burn an ordered v2 counter.
send: (item) => {
args.socket.send(
item.kind === 'text'
? args.session.sealText(item.plaintext)
: args.session.sealBinary(item.plaintext)
const cancelClaim = this.outboundSocketLedger.claimSentBytes(
outboundItemRetainedBytes(item),
item.kind === 'text' ? item.acknowledgementKey : undefined
)
if (!cancelClaim) {
this.failOutboundOverflow()
return
}
try {
args.socket.send(
item.kind === 'text'
? args.session.sealText(item.plaintext)
: args.session.sealBinary(item.plaintext)
)
} catch {
cancelClaim()
this.failOutboundOverflow()
}
},
byteLengthOf: (item) =>
(item.kind === 'text'
? new TextEncoder().encode(item.plaintext).length
: item.plaintext.length) + 82,
byteLengthOf: outboundItemRetainedBytes,
getBufferedAmount: () => args.socket.bufferedAmount,
isWritable: () => args.socket.readyState === args.socket.OPEN,
onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow'))
canSend: (bytes) => this.outboundSocketLedger.canSend(bytes),
claimQueuedBytes: (bytes) => outboundMemoryBudget.claimQueuedBytes(bytes),
maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES,
onOverflow: () => this.failOutboundOverflow()
})
}
@@ -63,19 +108,15 @@ export class MobileE2EEV2PhysicalChannel {
}
handleMessage(raw: unknown): Promise<void> {
const generation = this.generation
this.inboundChain = this.inboundChain
.then(() => this.processMessage(raw, generation))
.catch((error: unknown) => {
if (generation === this.generation) {
this.args.onError(error instanceof Error ? error : new Error(String(error)))
}
})
return this.inboundChain
return this.inboundQueue.enqueue(raw)
}
sendText(plaintext: string): boolean {
return this.enqueueReady({ kind: 'text', plaintext })
return this.enqueueReady({
kind: 'text',
plaintext,
acknowledgementKey: outboundTextAcknowledgementKey(plaintext)
})
}
sendBinary(plaintext: Uint8Array): boolean {
@@ -84,7 +125,14 @@ export class MobileE2EEV2PhysicalChannel {
dispose(): void {
this.generation++
this.inboundQueue.dispose()
this.outboundQueue.dispose()
this.outboundSocketLedger.retire()
}
socketClosed(): void {
this.dispose()
this.outboundSocketLedger.socketClosed()
}
private async processMessage(raw: unknown, generation: number): Promise<void> {
@@ -104,6 +152,7 @@ export class MobileE2EEV2PhysicalChannel {
return
}
if (this.state === 'awaiting-authenticated') {
this.outboundSocketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY)
if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
throw new MobileE2EEAuthenticationError()
}
@@ -113,6 +162,10 @@ export class MobileE2EEV2PhysicalChannel {
this.state = 'ready'
this.args.onAuthenticated()
} else if (typeof plaintext === 'string') {
const acknowledgementKey = outboundTextAcknowledgementKey(plaintext)
if (acknowledgementKey) {
this.outboundSocketLedger.acknowledge(acknowledgementKey)
}
this.args.onText(plaintext)
} else {
this.args.onBinary(plaintext)
@@ -125,7 +178,7 @@ export class MobileE2EEV2PhysicalChannel {
}
let ready: unknown
try {
ready = JSON.parse(raw)
ready = parseMobileJsonTextWithinLimits(raw)
} catch {
throw new Error('Invalid E2EE v2 ready JSON')
}
@@ -135,7 +188,8 @@ export class MobileE2EEV2PhysicalChannel {
this.state = 'awaiting-authenticated'
this.outboundQueue.enqueue({
kind: 'text',
plaintext: JSON.stringify({
acknowledgementKey: AUTHENTICATION_ACKNOWLEDGEMENT_KEY,
plaintext: stringifyMobileOutboundJson({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: this.args.session.transcriptHashB64,
@@ -149,12 +203,15 @@ export class MobileE2EEV2PhysicalChannel {
if (!bytes || generation !== this.generation) {
return null
}
if (bytes.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) {
throw new Error('E2EE v2 inbound frame too large')
}
return this.args.session.openBinary(bytes)
}
private isAuthenticated(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
const message = parseMobileJsonTextWithinLimits<Record<string, unknown>>(plaintext)
return (
Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' &&
message.type === 'e2ee_authenticated' &&
@@ -170,16 +227,41 @@ export class MobileE2EEV2PhysicalChannel {
if (this.state !== 'ready') {
return false
}
this.outboundQueue.enqueue(item)
return true
return this.outboundQueue.enqueue(item) && !this.outboundOverflowed
}
private failOutboundOverflow(): void {
if (this.outboundOverflowed) {
return
}
this.outboundOverflowed = true
this.args.onError(new Error('E2EE v2 outbound buffer overflow'))
}
}
function isAuthenticationRejection(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
const message = parseMobileJsonTextWithinLimits<Record<string, unknown>>(plaintext)
return message.type === 'e2ee_error'
} catch {
return false
}
}
function outboundItemRetainedBytes(item: OutboundItem): number {
if (item.kind === 'binary') {
return item.plaintext.byteLength + E2EE_V2_FRAME_OVERHEAD_BYTES
}
const encryptedBytes = getUtf8ByteLength(item.plaintext) + E2EE_V2_FRAME_OVERHEAD_BYTES
const wireBytes = Math.ceil(encryptedBytes / 3) * 4
return Math.max(wireBytes, item.plaintext.length * 2)
}
function outboundTextAcknowledgementKey(plaintext: string): string | undefined {
try {
const value = parseMobileJsonTextWithinLimits<{ id?: unknown }>(plaintext)
return typeof value.id === 'string' ? value.id : undefined
} catch {
return undefined
}
}
@@ -0,0 +1,43 @@
import { StoredHostProfileSchema, type StoredHostProfile } from './types'
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
export const STORED_HOSTS_MAX_ENTRIES = 256
export const STORED_HOSTS_MAX_STORAGE_CHARACTERS = 8 * 1024 * 1024
export function parseMobileStoredHostList(raw: string | null): StoredHostProfile[] | null {
if (!raw) {
return []
}
if (raw.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) {
return null
}
try {
const parsed = parseMobileJsonTextWithinLimits(raw)
if (!Array.isArray(parsed) || parsed.length > STORED_HOSTS_MAX_ENTRIES) {
return null
}
return parsed.flatMap((item) => {
// Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; dropping
// them keeps bearer material out of the metadata migration path.
if (item && typeof item === 'object' && 'deviceToken' in item) {
return []
}
const result = StoredHostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
}
export function serializeMobileStoredHostList(hosts: StoredHostProfile[]): string {
if (hosts.length > STORED_HOSTS_MAX_ENTRIES) {
throw new Error('host list storage limit exceeded')
}
const validated = hosts.map((host) => StoredHostProfileSchema.parse(host))
const serialized = JSON.stringify(validated)
if (serialized.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) {
throw new Error('host list storage limit exceeded')
}
return serialized
}
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest'
import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue'
import { createMobileInboundMemoryLedger } from './mobile-inbound-memory-ledger'
function expectEmpty(ledger: ReturnType<typeof createMobileInboundMemoryLedger>): void {
expect(ledger.evidence()).toMatchObject({ claimCount: 0, retainedBytes: 0 })
}
describe('mobile inbound frame queue aggregate memory', () => {
it('releases a claim after synchronous processing succeeds', async () => {
const ledger = createMobileInboundMemoryLedger(10)
const queue = createMobileInboundFrameQueue({
process: vi.fn(),
onError: vi.fn(),
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
memoryLedger: ledger
})
await queue.enqueue(new Uint8Array(3))
expectEmpty(ledger)
})
it('releases a claim after synchronous processing throws', async () => {
const ledger = createMobileInboundMemoryLedger(10)
const onError = vi.fn()
const queue = createMobileInboundFrameQueue({
process: () => {
throw new Error('processing failed')
},
onError,
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
memoryLedger: ledger
})
await queue.enqueue(new Uint8Array(3))
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' }))
expectEmpty(ledger)
})
it('rejects a frame when another physical queue owns the remaining process credit', async () => {
const ledger = createMobileInboundMemoryLedger(5)
let releaseFirst!: () => void
const first = createMobileInboundFrameQueue({
process: () => new Promise<void>((resolve) => (releaseFirst = resolve)),
onError: vi.fn(),
overflowMessage: 'first overflow',
frameTooLargeMessage: 'too large',
memoryLedger: ledger
})
const secondProcess = vi.fn()
const secondError = vi.fn()
const second = createMobileInboundFrameQueue({
process: secondProcess,
onError: secondError,
overflowMessage: 'aggregate overflow',
frameTooLargeMessage: 'too large',
memoryLedger: ledger
})
const firstPending = first.enqueue(new Uint8Array(3))
await second.enqueue(new Uint8Array(3))
expect(secondProcess).not.toHaveBeenCalled()
expect(secondError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'aggregate overflow' })
)
expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 })
releaseFirst()
await firstPending
expectEmpty(ledger)
})
it('releases current and queued claims after processing rejects', async () => {
const ledger = createMobileInboundMemoryLedger(10)
let rejectCurrent!: (error: Error) => void
const onError = vi.fn()
const queue = createMobileInboundFrameQueue({
process: () => new Promise<void>((_resolve, reject) => (rejectCurrent = reject)),
onError,
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
memoryLedger: ledger
})
const first = queue.enqueue(new Uint8Array(2))
const second = queue.enqueue(new Uint8Array(3))
expect(ledger.evidence()).toMatchObject({ claimCount: 2, retainedBytes: 5 })
rejectCurrent(new Error('processing failed'))
await Promise.all([first, second])
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' }))
expectEmpty(ledger)
})
it('releases queued claims on dispose and the active claim when processing settles', async () => {
const ledger = createMobileInboundMemoryLedger(10)
let releaseCurrent!: () => void
const queue = createMobileInboundFrameQueue({
process: () => new Promise<void>((resolve) => (releaseCurrent = resolve)),
onError: vi.fn(),
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
memoryLedger: ledger
})
const first = queue.enqueue(new Uint8Array(2))
const second = queue.enqueue(new Uint8Array(3))
queue.dispose()
expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 2 })
releaseCurrent()
await Promise.all([first, second])
expectEmpty(ledger)
})
it('releases every claim after per-queue overflow', async () => {
const ledger = createMobileInboundMemoryLedger(10)
let releaseCurrent!: () => void
const queue = createMobileInboundFrameQueue({
process: () => new Promise<void>((resolve) => (releaseCurrent = resolve)),
onError: vi.fn(),
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
maxBufferedBytes: 4,
memoryLedger: ledger
})
const first = queue.enqueue(new Uint8Array(3))
const rejected = queue.enqueue(new Uint8Array(2))
expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 })
releaseCurrent()
await Promise.all([first, rejected])
expectEmpty(ledger)
})
it('does not claim process memory for an individually oversized frame', async () => {
const ledger = createMobileInboundMemoryLedger(10)
const queue = createMobileInboundFrameQueue({
process: vi.fn(),
onError: vi.fn(),
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
maxFrameBytes: 2,
memoryLedger: ledger
})
await queue.enqueue(new Uint8Array(3))
expectEmpty(ledger)
})
})
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest'
import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue'
describe('mobile inbound frame queue', () => {
it('serializes async processing without losing frame order', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
const processed: string[] = []
const queue = createMobileInboundFrameQueue({
process: async (raw) => {
if (raw === 'first') {
await gate
}
processed.push(String(raw))
},
onError: vi.fn(),
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large'
})
const first = queue.enqueue('first')
const second = queue.enqueue('second')
expect(processed).toEqual([])
release()
await Promise.all([first, second])
expect(processed).toEqual(['first', 'second'])
})
it('does not retain processed frame slots while a steady stream keeps the queue busy', async () => {
let releaseCurrent!: () => void
const queue = createMobileInboundFrameQueue({
process: () => new Promise<void>((resolve) => (releaseCurrent = resolve)),
onError: vi.fn(),
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large'
})
let current = queue.enqueue({ byteLength: 1, id: 0 })
let next = queue.enqueue({ byteLength: 1, id: 1 })
for (let id = 2; id < 256; id += 1) {
releaseCurrent()
await current
current = next
next = queue.enqueue({ byteLength: 1, id })
expect(queue.evidence().storageSlots).toBeLessThanOrEqual(64)
}
releaseCurrent()
await current
releaseCurrent()
await next
expect(queue.evidence()).toEqual({ retainedBytes: 0, retainedFrames: 0, storageSlots: 0 })
})
it('drops and settles the backlog when retained frame count reaches its cap', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
const onError = vi.fn()
const queue = createMobileInboundFrameQueue({
process: async () => gate,
onError,
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
maxBufferedFrames: 2,
maxBufferedBytes: 100,
maxFrameBytes: 100
})
const pending = [
queue.enqueue(new Uint8Array(1)),
queue.enqueue(new Uint8Array(1)),
queue.enqueue(new Uint8Array(1))
]
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'overflow' }))
release()
await Promise.all(pending)
})
it('fails closed when aggregate retained bytes exceed their cap', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
const onError = vi.fn()
const queue = createMobileInboundFrameQueue({
process: async () => gate,
onError,
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
maxBufferedFrames: 10,
maxBufferedBytes: 5,
maxFrameBytes: 4
})
const first = queue.enqueue(new Uint8Array(3))
const second = queue.enqueue(new Uint8Array(3))
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'overflow' }))
release()
await Promise.all([first, second])
})
it('rejects a single known oversized frame without invoking its processor', async () => {
const process = vi.fn()
const onError = vi.fn()
const queue = createMobileInboundFrameQueue({
process,
onError,
overflowMessage: 'overflow',
frameTooLargeMessage: 'too large',
maxFrameBytes: 4
})
await queue.enqueue(new Uint8Array(5))
expect(process).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'too large' }))
})
})
@@ -0,0 +1,209 @@
import {
processMobileInboundMemoryLedger,
type MobileInboundMemoryLedger
} from './mobile-inbound-memory-ledger'
// Why: a valid 10 MiB image preview expands twice through nested base64 framing to ~36 MiB of JS text.
export const MOBILE_INBOUND_MAX_FRAME_BYTES = 64 * 1024 * 1024
// Why: two valid large previews may overlap, but a stalled consumer cannot retain a third.
export const MOBILE_INBOUND_MAX_BUFFERED_BYTES = 96 * 1024 * 1024
// Why: tiny-frame floods need a count bound independent of their serialized size.
export const MOBILE_INBOUND_MAX_BUFFERED_FRAMES = 64
type PendingFrame = {
raw: unknown
releaseMemory: () => void
retainedBytes: number
settle: () => void
}
export type MobileInboundFrameQueueEvidence = {
retainedBytes: number
retainedFrames: number
storageSlots: number
}
export type MobileInboundFrameQueue = {
enqueue(raw: unknown): Promise<void>
dispose(): void
evidence(): MobileInboundFrameQueueEvidence
}
const QUEUE_COMPACTION_HEAD_THRESHOLD = 64
export function createMobileInboundFrameQueue(options: {
process: (raw: unknown) => Promise<void> | void
onError: (error: Error) => void
overflowMessage: string
frameTooLargeMessage: string
maxFrameBytes?: number
maxBufferedBytes?: number
maxBufferedFrames?: number
memoryLedger?: MobileInboundMemoryLedger
}): MobileInboundFrameQueue {
const maxFrameBytes = options.maxFrameBytes ?? MOBILE_INBOUND_MAX_FRAME_BYTES
const maxBufferedBytes = options.maxBufferedBytes ?? MOBILE_INBOUND_MAX_BUFFERED_BYTES
const maxBufferedFrames = options.maxBufferedFrames ?? MOBILE_INBOUND_MAX_BUFFERED_FRAMES
const memoryLedger = options.memoryLedger ?? processMobileInboundMemoryLedger
const queue: Array<PendingFrame | undefined> = []
let queueHead = 0
let retainedBytes = 0
let retainedFrames = 0
let draining = false
let stopped = false
const release = (frame: PendingFrame): void => {
retainedBytes -= frame.retainedBytes
retainedFrames -= 1
frame.releaseMemory()
frame.settle()
}
const dropQueued = (): void => {
while (queueHead < queue.length) {
release(queue[queueHead++]!)
}
queue.length = 0
queueHead = 0
}
const fail = (error: Error): void => {
if (stopped) {
return
}
stopped = true
dropQueued()
options.onError(error)
}
const finishDrain = (): void => {
if (queueHead === queue.length) {
queue.length = 0
queueHead = 0
}
draining = false
}
const continueDrain = (): void => {
while (!stopped && queueHead < queue.length) {
const frame = queue[queueHead++]!
queue[queueHead - 1] = undefined
if (queueHead >= QUEUE_COMPACTION_HEAD_THRESHOLD) {
queue.splice(0, queueHead)
queueHead = 0
}
let processing: Promise<void> | void
try {
processing = options.process(frame.raw)
} catch (error) {
release(frame)
fail(error instanceof Error ? error : new Error(String(error)))
finishDrain()
return
}
if (processing) {
void processing.then(
() => {
release(frame)
if (stopped) {
finishDrain()
return
}
continueDrain()
},
(error: unknown) => {
release(frame)
fail(error instanceof Error ? error : new Error(String(error)))
finishDrain()
}
)
return
}
release(frame)
}
finishDrain()
}
const drain = (): void => {
if (draining || stopped) {
return
}
draining = true
continueDrain()
}
return {
enqueue(raw): Promise<void> {
if (stopped) {
return Promise.resolve()
}
const frameBytes = mobileInboundFrameRetainedBytes(raw, maxFrameBytes)
if (frameBytes > maxFrameBytes) {
fail(new Error(options.frameTooLargeMessage))
return Promise.resolve()
}
if (retainedFrames >= maxBufferedFrames || retainedBytes + frameBytes > maxBufferedBytes) {
fail(new Error(options.overflowMessage))
return Promise.resolve()
}
const releaseMemory = memoryLedger.claim(frameBytes)
if (!releaseMemory) {
fail(new Error(options.overflowMessage))
return Promise.resolve()
}
return new Promise((settle) => {
queue.push({ raw, releaseMemory, retainedBytes: frameBytes, settle })
retainedBytes += frameBytes
retainedFrames += 1
drain()
})
},
dispose(): void {
if (stopped) {
return
}
stopped = true
dropQueued()
},
evidence(): MobileInboundFrameQueueEvidence {
return { retainedBytes, retainedFrames, storageSlots: queue.length }
}
}
}
export function assertMobileInboundFrameSize(
raw: unknown,
message: string,
maxFrameBytes = MOBILE_INBOUND_MAX_FRAME_BYTES
): void {
if (mobileInboundFrameRetainedBytes(raw, maxFrameBytes) > maxFrameBytes) {
throw new Error(message)
}
}
function mobileInboundFrameRetainedBytes(raw: unknown, unknownFrameBytes: number): number {
if (typeof raw === 'string') {
return raw.length * 2
}
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
return raw.byteLength
}
if (raw && typeof raw === 'object') {
const size = numericProperty(raw, 'size') ?? numericProperty(raw, 'byteLength')
if (size !== null) {
return size
}
}
// Why: unknown RN payload wrappers may materialize their bytes only during async conversion.
return unknownFrameBytes
}
function numericProperty(value: object, key: 'size' | 'byteLength'): number | null {
if (!(key in value)) {
return null
}
const candidate = (value as Record<string, unknown>)[key]
return typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0
? candidate
: null
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
createMobileInboundMemoryLedger,
MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES,
MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES
} from './mobile-inbound-memory-ledger'
describe('mobile inbound memory ledger', () => {
it('uses a process ceiling that preserves two full ordinary queue budgets', () => {
expect(MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES).toBe(192 * 1024 * 1024)
expect(MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES).toBe(256)
})
it('makes claims idempotently releasable', () => {
const ledger = createMobileInboundMemoryLedger(5)
const release = ledger.claim(5)
expect(release).not.toBeNull()
expect(ledger.claim(1)).toBeNull()
release!()
release!()
expect(ledger.evidence()).toEqual({
claimCount: 0,
maxRetainedBytes: 5,
maxRetainedFrames: MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES,
retainedBytes: 0
})
})
it('bounds aggregate zero-byte claims independently of the byte ceiling', () => {
const ledger = createMobileInboundMemoryLedger(5, 2)
const releaseFirst = ledger.claim(0)
const releaseSecond = ledger.claim(0)
expect(releaseFirst).not.toBeNull()
expect(releaseSecond).not.toBeNull()
expect(ledger.claim(0)).toBeNull()
releaseFirst!()
releaseSecond!()
})
})
@@ -0,0 +1,58 @@
export const MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES = 192 * 1024 * 1024
export const MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES = 256
export type MobileInboundMemoryLedgerEvidence = {
claimCount: number
maxRetainedFrames: number
maxRetainedBytes: number
retainedBytes: number
}
export type MobileInboundMemoryLedger = {
claim(bytes: number): (() => void) | null
evidence(): MobileInboundMemoryLedgerEvidence
}
export function createMobileInboundMemoryLedger(
maxRetainedBytes: number,
maxRetainedFrames = MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES
): MobileInboundMemoryLedger {
if (
!Number.isFinite(maxRetainedBytes) ||
maxRetainedBytes < 1 ||
!Number.isInteger(maxRetainedFrames) ||
maxRetainedFrames < 1
) {
throw new Error('Mobile inbound memory limit must be positive')
}
let retainedBytes = 0
let claimCount = 0
return {
claim(bytes): (() => void) | null {
if (!Number.isFinite(bytes) || bytes < 0) {
return null
}
if (claimCount >= maxRetainedFrames || retainedBytes + bytes > maxRetainedBytes) {
return null
}
retainedBytes += bytes
claimCount += 1
let released = false
return () => {
if (released) {
return
}
released = true
retainedBytes -= bytes
claimCount -= 1
}
},
evidence(): MobileInboundMemoryLedgerEvidence {
return { claimCount, maxRetainedBytes, maxRetainedFrames, retainedBytes }
}
}
}
export const processMobileInboundMemoryLedger = createMobileInboundMemoryLedger(
MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES
)
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import {
isMobileJsonStructureCapacityError,
parseMobileJsonTextWithinLimits
} from './mobile-json-text-admission'
describe('mobile JSON text admission', () => {
it('accepts the exact structural-token limit and rejects one more', () => {
const limits = { structuralTokens: 4, nestingDepth: 2 }
expect(parseMobileJsonTextWithinLimits('[0,0,0]', limits)).toEqual([0, 0, 0])
expect(() => parseMobileJsonTextWithinLimits('[0,0,0,0]', limits)).toThrow(
'JSON structure exceeds 4 tokens'
)
})
it('accepts the exact nesting limit and rejects one more', () => {
const limits = { structuralTokens: 20, nestingDepth: 3 }
expect(parseMobileJsonTextWithinLimits('[[[]]]', limits)).toEqual([[[]]])
expect(() => parseMobileJsonTextWithinLimits('[[[[]]]]', limits)).toThrow(
'JSON nesting exceeds 3 levels'
)
})
it('does not count structural characters inside strings', () => {
expect(
parseMobileJsonTextWithinLimits('{"value":"[{\\\":,}]"}', {
structuralTokens: 3,
nestingDepth: 1
})
).toEqual({ value: '[{":,}]' })
})
it('identifies capacity failures separately from malformed JSON', () => {
const capacityError = captureError(() =>
parseMobileJsonTextWithinLimits('[0,0]', {
structuralTokens: 2,
nestingDepth: 2
})
)
const syntaxError = captureError(() =>
parseMobileJsonTextWithinLimits('[', {
structuralTokens: 2,
nestingDepth: 2
})
)
expect(isMobileJsonStructureCapacityError(capacityError)).toBe(true)
expect(isMobileJsonStructureCapacityError(syntaxError)).toBe(false)
})
})
function captureError(run: () => void): unknown {
try {
run()
return null
} catch (error) {
return error
}
}
@@ -0,0 +1,38 @@
import {
assertJsonTextStructureWithinLimits,
JsonTextStructureCapacityError,
type JsonTextStructureLimits
} from '../../../src/shared/memory-safety/json-text-structure-limit'
export const MOBILE_JSON_TEXT_STRUCTURE_LIMITS: JsonTextStructureLimits = {
structuralTokens: 1_000_000,
nestingDepth: 128
}
export function parseMobileJsonTextWithinLimits<T = unknown>(
content: string,
limits: JsonTextStructureLimits = MOBILE_JSON_TEXT_STRUCTURE_LIMITS
): T {
assertJsonTextStructureWithinLimits(content, limits)
return JSON.parse(content) as T
}
export function isMobileJsonStructureCapacityError(
error: unknown
): error is JsonTextStructureCapacityError {
return error instanceof JsonTextStructureCapacityError
}
export function tryParseMobileJsonTextWithinLimits<T = unknown>(
content: string,
limits: JsonTextStructureLimits = MOBILE_JSON_TEXT_STRUCTURE_LIMITS
): T | null {
try {
return parseMobileJsonTextWithinLimits<T>(content, limits)
} catch (error) {
if (isMobileJsonStructureCapacityError(error)) {
throw error
}
return null
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { stringifyMobileOutboundJson } from './mobile-outbound-json'
describe('bounded mobile outbound JSON serialization', () => {
it('preserves ordinary JSON values', () => {
const value = { text: 'hello', escaped: '\u0000\n', items: [1, true, null] }
expect(stringifyMobileOutboundJson(value, 1_000)).toBe(JSON.stringify(value))
})
it('stops before traversing fields after an oversized string', () => {
const readAfter = vi.fn(() => 'late')
const value = {
payload: 'x'.repeat(100),
get after() {
return readAfter()
}
}
expect(() => stringifyMobileOutboundJson(value, 32)).toThrow(
'Mobile outbound JSON exceeds 32 bytes'
)
expect(readAfter).not.toHaveBeenCalled()
})
it('accounts for JSON escaping before the native serializer allocates output', () => {
expect(stringifyMobileOutboundJson('\u0000', 8)).toBe('"\\u0000"')
expect(() => stringifyMobileOutboundJson('\u0000', 7)).toThrow(
'Mobile outbound JSON exceeds 7 bytes'
)
})
it('accepts arrays and objects whose serialized form is exactly at the limit', () => {
expect(stringifyMobileOutboundJson([1, 2], 5)).toBe('[1,2]')
expect(stringifyMobileOutboundJson({ a: 1 }, 7)).toBe('{"a":1}')
expect(() => stringifyMobileOutboundJson([1, 2], 4)).toThrow(
'Mobile outbound JSON exceeds 4 bytes'
)
expect(() => stringifyMobileOutboundJson({ a: 1 }, 6)).toThrow(
'Mobile outbound JSON exceeds 6 bytes'
)
})
it('does not charge object properties omitted by JSON.stringify', () => {
const value = {
omittedUndefined: undefined,
omittedFunction: () => 'ignored',
omittedSymbol: Symbol('ignored'),
kept: 1
}
expect(stringifyMobileOutboundJson(value, 10)).toBe('{"kept":1}')
})
it('serializes unsupported array elements as null within the exact limit', () => {
const value = [undefined, () => 'ignored', Symbol('ignored')]
expect(stringifyMobileOutboundJson(value, 16)).toBe('[null,null,null]')
expect(() => stringifyMobileOutboundJson(value, 15)).toThrow(
'Mobile outbound JSON exceeds 15 bytes'
)
})
it('accounts for boxed JSON primitives without underestimating them', () => {
expect(stringifyMobileOutboundJson(new String('\u0000'), 8)).toBe('"\\u0000"')
expect(stringifyMobileOutboundJson(new Number(12345), 5)).toBe('12345')
expect(stringifyMobileOutboundJson(new Boolean(false), 5)).toBe('false')
expect(() => stringifyMobileOutboundJson(new String('\u0000'), 7)).toThrow(
'Mobile outbound JSON exceeds 7 bytes'
)
})
})
@@ -0,0 +1,123 @@
export const MOBILE_OUTBOUND_MAX_JSON_BYTES = 4 * 1024 * 1024
export function stringifyMobileOutboundJson(
value: unknown,
maxBytes = MOBILE_OUTBOUND_MAX_JSON_BYTES
): string {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
throw new Error('Mobile outbound JSON limit must be a positive safe integer')
}
let estimatedBytes = 0
let root = true
const emittedMembers = new WeakMap<object, number>()
const serialized = JSON.stringify(value, function (key, item: unknown) {
const isRoot = root
root = false
const parent = this as object
const inArray = Array.isArray(parent)
if (!isRoot && !inArray && isOmittedObjectValue(item)) {
return item
}
if (!isRoot) {
const emitted = emittedMembers.get(parent) ?? 0
estimatedBytes += emitted > 0 ? 1 : 0
if (!inArray) {
estimatedBytes += escapedJsonStringBytes(key) + 1
}
emittedMembers.set(parent, emitted + 1)
}
estimatedBytes += inArray && isOmittedObjectValue(item) ? 4 : jsonValueBytes(item)
if (estimatedBytes > maxBytes) {
throw new Error(`Mobile outbound JSON exceeds ${maxBytes} bytes`)
}
return item
})
if (serialized === undefined) {
throw new Error('Mobile outbound value is not JSON serializable')
}
return serialized
}
function isOmittedObjectValue(value: unknown): boolean {
return value === undefined || typeof value === 'function' || typeof value === 'symbol'
}
function jsonValueBytes(value: unknown): number {
if (value === null) {
return 4
}
if (typeof value === 'string') {
return escapedJsonStringBytes(value)
}
if (typeof value === 'boolean') {
return value ? 4 : 5
}
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value).length : 4
}
if (typeof value === 'object' && value !== null) {
return boxedPrimitiveJsonBytes(value) ?? 2
}
return 4
}
function boxedPrimitiveJsonBytes(value: object): number | null {
try {
return escapedJsonStringBytes(String.prototype.valueOf.call(value))
} catch {}
try {
const number = Number.prototype.valueOf.call(value)
return Number.isFinite(number) ? String(number).length : 4
} catch {}
try {
return Boolean.prototype.valueOf.call(value) ? 4 : 5
} catch {
return null
}
}
function escapedJsonStringBytes(value: string): number {
let bytes = 2
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (
code === 0x22 ||
code === 0x5c ||
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d
) {
bytes += 2
} else if (
code <= 0x1f ||
(code >= 0xd800 && code <= 0xdfff && !isSurrogatePair(value, index))
) {
bytes += 6
} else if (code <= 0x7f) {
bytes += 1
} else if (code <= 0x7ff) {
bytes += 2
} else if (code >= 0xd800 && code <= 0xdbff) {
bytes += 4
index += 1
} else {
bytes += 3
}
}
return bytes
}
function isSurrogatePair(value: string, index: number): boolean {
const code = value.charCodeAt(index)
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
return next >= 0xdc00 && next <= 0xdfff
}
if (code >= 0xdc00 && code <= 0xdfff) {
const previous = value.charCodeAt(index - 1)
return previous >= 0xd800 && previous <= 0xdbff
}
return false
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import {
createMobileOutboundMemoryBudget,
MOBILE_OUTBOUND_MAX_FRAME_BYTES,
MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES,
MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES,
MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES,
MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES,
MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES
} from './mobile-outbound-memory-budget'
describe('mobile outbound memory budget', () => {
it('keeps ordinary two-socket bursts within explicit process ceilings', () => {
expect(MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES).toBe(16 * 1024 * 1024)
expect(MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES).toBe(16_384)
expect(MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES).toBe(128 * 1024 * 1024)
expect(MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES).toBe(16_384)
expect(MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES).toBe(64)
expect(MOBILE_OUTBOUND_MAX_FRAME_BYTES).toBe(8 * 1024 * 1024)
})
it('sums native bufferedAmount across registered physical sockets', () => {
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
let first = 4
const firstSource = budget.registerBufferedAmount(() => first)
const secondSource = budget.registerBufferedAmount(() => 6)
expect(firstSource.canSend(0)).toBe(true)
first = 5
expect(firstSource.canSend(0)).toBe(false)
secondSource.release()
firstSource.release()
expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 })
})
it('reserves room for the prospective native frame before allowing a send', () => {
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const source = budget.registerBufferedAmount(() => 6)
expect(source.canSend(4)).toBe(true)
expect(source.canSend(5)).toBe(false)
})
it('counts unacknowledged frames when React Native does not expose bufferedAmount', () => {
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const source = budget.registerBufferedAmount(() => Number.NaN)
const release = source.claimInFlightBytes(7)
expect(release).not.toBeNull()
expect(source.canSend(4)).toBe(false)
expect(budget.evidence()).toMatchObject({
bufferedBytes: 7,
inFlightBytes: 7,
inFlightClaimCount: 1
})
release!()
expect(source.canSend(10)).toBe(true)
})
it('caps sent-frame claims and retired socket readers independently', () => {
const budget = createMobileOutboundMemoryBudget({
maxBufferedBytes: 10,
maxBufferedSources: 1,
maxInFlightFrames: 1
})
const source = budget.registerBufferedAmount(() => 0)
const release = source.claimInFlightBytes(0)
expect(release).not.toBeNull()
expect(source.claimInFlightBytes(0)).toBeNull()
expect(budget.canRegisterBufferedAmount()).toBe(false)
expect(() => budget.registerBufferedAmount(() => 0)).toThrow(
'Mobile outbound socket tracking limit exceeded'
)
release!()
source.release()
expect(budget.canRegisterBufferedAmount()).toBe(true)
})
it('bounds aggregate JavaScript backlog and releases claims idempotently', () => {
const budget = createMobileOutboundMemoryBudget({ maxQueuedBytes: 5 })
const release = budget.claimQueuedBytes(5)
expect(release).not.toBeNull()
expect(budget.claimQueuedBytes(1)).toBeNull()
release!()
release!()
expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 })
})
it('bounds aggregate zero-byte claims independently of the byte ceiling', () => {
const budget = createMobileOutboundMemoryBudget({ maxQueuedBytes: 5, maxQueuedFrames: 2 })
const releaseFirst = budget.claimQueuedBytes(0)
const releaseSecond = budget.claimQueuedBytes(0)
expect(releaseFirst).not.toBeNull()
expect(releaseSecond).not.toBeNull()
expect(budget.claimQueuedBytes(0)).toBeNull()
releaseFirst!()
releaseSecond!()
})
})
@@ -0,0 +1,188 @@
export const MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
export const MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES = 128 * 1024 * 1024
export const MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES = 16_384
export const MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES = 16_384
export const MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES = 64
export const MOBILE_OUTBOUND_MAX_FRAME_BYTES = 8 * 1024 * 1024
export type MobileOutboundMemoryBudgetEvidence = {
bufferedBytes: number
bufferedSourceCount: number
inFlightBytes: number
inFlightClaimCount: number
queuedBytes: number
queuedClaimCount: number
}
export type MobileOutboundSocketMemory = {
canSend(bytes: number): boolean
claimInFlightBytes(bytes: number): (() => void) | null
release(): void
}
export type MobileOutboundMemoryBudget = {
canRegisterBufferedAmount(): boolean
claimQueuedBytes(bytes: number): (() => void) | null
evidence(): MobileOutboundMemoryBudgetEvidence
registerBufferedAmount(readBufferedAmount: () => number): MobileOutboundSocketMemory
}
export function createMobileOutboundMemoryBudget(options?: {
maxBufferedBytes?: number
maxBufferedSources?: number
maxInFlightFrames?: number
maxQueuedBytes?: number
maxQueuedFrames?: number
}): MobileOutboundMemoryBudget {
const maxBufferedBytes = options?.maxBufferedBytes ?? MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES
const maxBufferedSources =
options?.maxBufferedSources ?? MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES
const maxInFlightFrames =
options?.maxInFlightFrames ?? MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES
const maxQueuedBytes = options?.maxQueuedBytes ?? MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES
const maxQueuedFrames = options?.maxQueuedFrames ?? MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES
if (
!Number.isFinite(maxBufferedBytes) ||
maxBufferedBytes < 1 ||
!Number.isInteger(maxBufferedSources) ||
maxBufferedSources < 1 ||
!Number.isInteger(maxInFlightFrames) ||
maxInFlightFrames < 1 ||
!Number.isFinite(maxQueuedBytes) ||
maxQueuedBytes < 1 ||
!Number.isInteger(maxQueuedFrames) ||
maxQueuedFrames < 1
) {
throw new Error('Mobile outbound memory limits must be positive')
}
type BufferedSource = {
inFlightBytes: number
inFlightClaimCount: number
readBufferedAmount: () => number
}
const bufferedSources = new Set<BufferedSource>()
let queuedBytes = 0
let queuedClaimCount = 0
const bufferedBytes = (): number => {
let total = 0
for (const source of bufferedSources) {
let observed = 0
try {
const value = source.readBufferedAmount()
if (Number.isFinite(value) && value > 0) {
observed = value
}
} catch {
// Closed native sockets may reject late reads; their close path unregisters them.
}
// Some runtimes update bufferedAmount asynchronously, so unacknowledged
// claims remain additive instead of assuming the native value includes them.
total += observed + source.inFlightBytes
}
return total
}
const inFlightClaimCount = (): number => {
let count = 0
for (const source of bufferedSources) {
count += source.inFlightClaimCount
}
return count
}
const inFlightBytes = (): number => {
let total = 0
for (const source of bufferedSources) {
total += source.inFlightBytes
}
return total
}
return {
canRegisterBufferedAmount: () => bufferedSources.size < maxBufferedSources,
claimQueuedBytes(bytes): (() => void) | null {
if (
!Number.isFinite(bytes) ||
bytes < 0 ||
queuedClaimCount >= maxQueuedFrames ||
queuedBytes + bytes > maxQueuedBytes
) {
return null
}
queuedBytes += bytes
queuedClaimCount += 1
let released = false
return () => {
if (released) {
return
}
released = true
queuedBytes -= bytes
queuedClaimCount -= 1
}
},
evidence(): MobileOutboundMemoryBudgetEvidence {
return {
bufferedBytes: bufferedBytes(),
bufferedSourceCount: bufferedSources.size,
inFlightBytes: inFlightBytes(),
inFlightClaimCount: inFlightClaimCount(),
queuedBytes,
queuedClaimCount
}
},
registerBufferedAmount(readBufferedAmount): MobileOutboundSocketMemory {
if (bufferedSources.size >= maxBufferedSources) {
throw new Error('Mobile outbound socket tracking limit exceeded')
}
const source: BufferedSource = {
inFlightBytes: 0,
inFlightClaimCount: 0,
readBufferedAmount
}
bufferedSources.add(source)
let registered = true
return {
canSend(bytes): boolean {
return (
registered &&
Number.isFinite(bytes) &&
bytes >= 0 &&
inFlightClaimCount() < maxInFlightFrames &&
bytes <= maxBufferedBytes - bufferedBytes()
)
},
claimInFlightBytes(bytes): (() => void) | null {
if (!this.canSend(bytes)) {
return null
}
source.inFlightBytes += bytes
source.inFlightClaimCount += 1
let released = false
return () => {
if (released) {
return
}
released = true
if (registered) {
source.inFlightBytes -= bytes
source.inFlightClaimCount -= 1
}
}
},
release(): void {
if (!registered) {
return
}
registered = false
bufferedSources.delete(source)
source.inFlightBytes = 0
source.inFlightClaimCount = 0
}
}
}
}
}
export const processMobileOutboundMemoryBudget = createMobileOutboundMemoryBudget()
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget'
import {
createMobileOutboundSocketLedger,
MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS
} from './mobile-outbound-socket-ledger'
describe('mobile outbound socket ledger', () => {
afterEach(() => vi.useRealTimers())
it('releases only the matching delivered frame while the socket stays open', () => {
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const ledger = createMobileOutboundSocketLedger({
memoryBudget: budget,
readBufferedAmount: () => Number.NaN
})
expect(ledger.claimSentBytes(4, 'rpc-1')).not.toBeNull()
expect(ledger.claimSentBytes(5, 'rpc-2')).not.toBeNull()
expect(ledger.canSend(2)).toBe(false)
ledger.acknowledge('rpc-1')
expect(budget.evidence()).toMatchObject({ inFlightBytes: 5, inFlightClaimCount: 1 })
expect(ledger.canSend(5)).toBe(true)
})
it('keeps unacknowledged bytes counted across logical disposal until native close', () => {
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const ledger = createMobileOutboundSocketLedger({
memoryBudget: budget,
readBufferedAmount: () => Number.NaN
})
expect(ledger.claimSentBytes(6, 'rpc-1')).not.toBeNull()
expect(budget.evidence()).toMatchObject({ bufferedBytes: 6, bufferedSourceCount: 1 })
ledger.socketClosed()
ledger.socketClosed()
expect(budget.evidence()).toMatchObject({
bufferedBytes: 0,
bufferedSourceCount: 0,
inFlightClaimCount: 0
})
})
it('releases a retired socket after a missing native close callback', () => {
vi.useFakeTimers()
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const ledger = createMobileOutboundSocketLedger({
memoryBudget: budget,
readBufferedAmount: () => Number.NaN
})
expect(ledger.claimSentBytes(6, 'rpc-1')).not.toBeNull()
ledger.retire()
vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS - 1)
expect(budget.evidence().bufferedSourceCount).toBe(1)
vi.advanceTimersByTime(1)
expect(budget.evidence()).toMatchObject({
bufferedBytes: 0,
bufferedSourceCount: 0,
inFlightClaimCount: 0
})
})
it('settles unkeyed frames after a runtime reports two drained polls', () => {
vi.useFakeTimers()
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const ledger = createMobileOutboundSocketLedger({
memoryBudget: budget,
readBufferedAmount: () => 0
})
expect(ledger.claimSentBytes(6)).not.toBeNull()
vi.advanceTimersByTime(50)
expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 })
ledger.socketClosed()
})
it('requires two fresh drained polls after another unkeyed frame is sent', () => {
vi.useFakeTimers()
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const ledger = createMobileOutboundSocketLedger({
memoryBudget: budget,
readBufferedAmount: () => 0
})
expect(ledger.claimSentBytes(4)).not.toBeNull()
vi.advanceTimersByTime(25)
expect(ledger.claimSentBytes(2)).not.toBeNull()
vi.advanceTimersByTime(25)
expect(budget.evidence()).toMatchObject({ inFlightBytes: 6, inFlightClaimCount: 2 })
vi.advanceTimersByTime(25)
expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 })
ledger.socketClosed()
})
it('keeps unkeyed React Native claims until close when bufferedAmount is unavailable', () => {
vi.useFakeTimers()
const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 })
const ledger = createMobileOutboundSocketLedger({
memoryBudget: budget,
readBufferedAmount: () => Number.NaN
})
expect(ledger.claimSentBytes(6)).not.toBeNull()
vi.advanceTimersByTime(1_000)
expect(budget.evidence()).toMatchObject({ inFlightBytes: 6, inFlightClaimCount: 1 })
ledger.socketClosed()
expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 })
})
})
@@ -0,0 +1,151 @@
import type { MobileOutboundMemoryBudget } from './mobile-outbound-memory-budget'
type InFlightClaim = {
acknowledgementKey?: string
releaseBytes: () => void
}
export const MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS = 30_000
const NATIVE_DRAIN_POLL_MS = 25
export type MobileOutboundSocketLedger = {
acknowledge(key: string): void
canSend(bytes: number): boolean
claimSentBytes(bytes: number, acknowledgementKey?: string): (() => void) | null
retire(): void
socketClosed(): void
}
export function createMobileOutboundSocketLedger(args: {
memoryBudget: MobileOutboundMemoryBudget
readBufferedAmount: () => number
}): MobileOutboundSocketLedger {
const socketMemory = args.memoryBudget.registerBufferedAmount(args.readBufferedAmount)
const activeClaims = new Set<InFlightClaim>()
const anonymousClaims = new Set<InFlightClaim>()
const claimsByAcknowledgement = new Map<string, Set<InFlightClaim>>()
let drainTimer: ReturnType<typeof setTimeout> | null = null
let retireTimer: ReturnType<typeof setTimeout> | null = null
let consecutiveZeroDrainPolls = 0
let closed = false
const settleClaim = (claim: InFlightClaim): void => {
if (!activeClaims.delete(claim)) {
return
}
claim.releaseBytes()
if (claim.acknowledgementKey === undefined) {
anonymousClaims.delete(claim)
return
}
const keyed = claimsByAcknowledgement.get(claim.acknowledgementKey)
keyed?.delete(claim)
if (keyed?.size === 0) {
claimsByAcknowledgement.delete(claim.acknowledgementKey)
}
}
const clearTimer = (timer: ReturnType<typeof setTimeout> | null): void => {
if (timer) {
clearTimeout(timer)
}
}
const pollNativeDrain = (): void => {
drainTimer = null
if (closed || anonymousClaims.size === 0) {
return
}
let bufferedAmount: number
try {
bufferedAmount = args.readBufferedAmount()
} catch {
return
}
// React Native leaves this undefined; keyed application ACKs remain the authoritative path there.
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
return
}
consecutiveZeroDrainPolls = bufferedAmount === 0 ? consecutiveZeroDrainPolls + 1 : 0
if (consecutiveZeroDrainPolls >= 2) {
for (const claim of anonymousClaims) {
settleClaim(claim)
}
return
}
drainTimer = setTimeout(pollNativeDrain, NATIVE_DRAIN_POLL_MS)
unrefTimer(drainTimer)
}
const scheduleNativeDrainPoll = (): void => {
if (drainTimer || closed) {
return
}
drainTimer = setTimeout(pollNativeDrain, NATIVE_DRAIN_POLL_MS)
unrefTimer(drainTimer)
}
const closeLedger = (): void => {
if (closed) {
return
}
closed = true
clearTimer(drainTimer)
clearTimer(retireTimer)
drainTimer = null
retireTimer = null
for (const claim of activeClaims) {
settleClaim(claim)
}
anonymousClaims.clear()
claimsByAcknowledgement.clear()
socketMemory.release()
}
return {
acknowledge(key): void {
const claims = claimsByAcknowledgement.get(key)
if (!claims) {
return
}
for (const claim of claims) {
settleClaim(claim)
}
},
canSend: (bytes) => socketMemory.canSend(bytes),
claimSentBytes(bytes, acknowledgementKey): (() => void) | null {
const releaseBytes = socketMemory.claimInFlightBytes(bytes)
if (!releaseBytes) {
return null
}
const claim: InFlightClaim = { acknowledgementKey, releaseBytes }
activeClaims.add(claim)
if (acknowledgementKey === undefined) {
anonymousClaims.add(claim)
consecutiveZeroDrainPolls = 0
scheduleNativeDrainPoll()
} else {
let claims = claimsByAcknowledgement.get(acknowledgementKey)
if (!claims) {
claims = new Set()
claimsByAcknowledgement.set(acknowledgementKey, claims)
}
claims.add(claim)
}
return () => settleClaim(claim)
},
retire(): void {
if (closed || retireTimer) {
return
}
retireTimer = setTimeout(closeLedger, MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS)
unrefTimer(retireTimer)
},
socketClosed: closeLedger
}
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
const nodeTimer = timer as unknown as { unref?: () => void }
nodeTimer.unref?.()
}
@@ -14,12 +14,14 @@ vi.mock('expo-secure-store', () => ({
vi.mock('react-native', () => ({ Platform: platform }))
import {
MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS,
deleteMobileRelayCredentialBundle,
promotePairingJournalCredential,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { MOBILE_HOST_ID_MAX_CHARACTERS, PAIRING_DEVICE_TOKEN_MAX_CHARACTERS } from './types'
const journal = {
metadata: {
@@ -106,6 +108,51 @@ describe('mobile relay credential bundle', () => {
).toThrow(/does not match/)
})
it('round-trips exact field and raw limits and rejects one character more', async () => {
const exact = {
v: 1 as const,
hostId: 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS),
deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS),
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 1,
expiresAt: 10_000
}
}
await expect(writeMobileRelayCredentialBundle(exact)).resolves.toBeUndefined()
const serialized = stored!
stored =
serialized +
' '.repeat(MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS - serialized.length)
await expect(readMobileRelayCredentialBundle(exact.hostId)).resolves.toEqual(exact)
await expect(
writeMobileRelayCredentialBundle({
...exact,
deviceToken: `${exact.deviceToken}t`
})
).rejects.toThrow()
})
it('does not parse an oversized secure-store record', async () => {
stored = {
length: MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS + 1
} as unknown as string
const parse = vi.spyOn(JSON, 'parse')
await expect(readMobileRelayCredentialBundle('host-1')).resolves.toBeNull()
expect(parse).not.toHaveBeenCalled()
parse.mockRestore()
})
it('does not construct a secure-store key for an oversized host id', async () => {
await expect(
readMobileRelayCredentialBundle('h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1))
).resolves.toBeNull()
expect(secureStore.getItemAsync).not.toHaveBeenCalled()
})
it('deletes the namespaced bundle and never enables it on web', async () => {
await deleteMobileRelayCredentialBundle('host-1')
expect(secureStore.deleteItemAsync).toHaveBeenCalledWith(
@@ -3,8 +3,15 @@ import { Platform } from 'react-native'
import { z } from 'zod'
import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
MOBILE_HOST_ID_MAX_CHARACTERS,
MobileHostIdSchema,
PAIRING_DEVICE_TOKEN_MAX_CHARACTERS
} from './types'
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS = 128 * 1024
const ResumeCredentialSchema = z
.object({
token: Base64Url32ByteSchema,
@@ -17,15 +24,15 @@ const ResumeCredentialSchema = z
export const MobileRelayCredentialBundleSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
deviceToken: z.string().min(1),
hostId: MobileHostIdSchema,
deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS),
current: ResumeCredentialSchema,
grace: ResumeCredentialSchema.optional(),
pending: z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
reqId: z.string().min(1)
reqId: z.string().min(1).max(128)
})
.strict()
.optional(),
@@ -74,12 +81,15 @@ export async function readMobileRelayCredentialBundle(
hostId: string
): Promise<MobileRelayCredentialBundle | null> {
requireNativeSecretStore()
if (!isValidHostId(hostId)) {
return null
}
const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
if (raw === null) {
if (raw === null || raw.length > MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS) {
return null
}
try {
const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw))
const result = MobileRelayCredentialBundleSchema.safeParse(parseMobileJsonTextWithinLimits(raw))
return result.success && result.data.hostId === hostId ? result.data : null
} catch {
return null
@@ -91,20 +101,24 @@ export async function writeMobileRelayCredentialBundle(
): Promise<void> {
requireNativeSecretStore()
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
await SecureStore.setItemAsync(
credentialKey(validated.hostId),
JSON.stringify(validated),
KEYCHAIN_OPTIONS
)
const serialized = JSON.stringify(validated)
if (serialized.length > MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS) {
throw new Error('mobile relay credential bundle exceeds storage limit')
}
await SecureStore.setItemAsync(credentialKey(validated.hostId), serialized, KEYCHAIN_OPTIONS)
}
export async function deleteMobileRelayCredentialBundle(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
if (Platform.OS === 'web' || !isValidHostId(hostId)) {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
}
function isValidHostId(hostId: string): boolean {
return hostId.length > 0 && hostId.length <= MOBILE_HOST_ID_MAX_CHARACTERS
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay credentials require a native secret store')
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
}))
vi.mock('react-native', () => ({ Platform: platform }))
import {
MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS,
createMobileRelayDirectUpgradeJournal,
readMobileRelayDirectUpgradeJournal,
writeMobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import { MOBILE_HOST_ID_MAX_CHARACTERS } from './types'
describe('mobile relay direct-upgrade journal storage', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
platform.OS = 'ios'
stored = null
secureStore.getItemAsync.mockImplementation(async () => stored)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
stored = value
})
})
it('round-trips the exact host-id and raw limits', async () => {
const hostId = 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS)
const journal = createMobileRelayDirectUpgradeJournal(hostId, (length) =>
new Uint8Array(length).fill(7)
)
await writeMobileRelayDirectUpgradeJournal(journal)
const serialized = stored!
stored =
serialized +
' '.repeat(MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS - serialized.length)
await expect(readMobileRelayDirectUpgradeJournal(hostId)).resolves.toEqual(journal)
})
it('rejects a host id one character beyond the limit', () => {
expect(() =>
createMobileRelayDirectUpgradeJournal(
'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1),
(length) => new Uint8Array(length)
)
).toThrow()
})
it('does not parse an oversized secure-store record', async () => {
stored = {
length: MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS + 1
} as unknown as string
const parse = vi.spyOn(JSON, 'parse')
await expect(readMobileRelayDirectUpgradeJournal('host-1')).resolves.toBeNull()
expect(parse).not.toHaveBeenCalled()
parse.mockRestore()
})
it('does not construct a secure-store key for an oversized host id', async () => {
await expect(
readMobileRelayDirectUpgradeJournal('h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1))
).resolves.toBeNull()
expect(secureStore.getItemAsync).not.toHaveBeenCalled()
})
})

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