mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Vendored
+20
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+18
-24
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+18
-27
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user