Merge remote-tracking branch 'origin/main' into brennanb2025/sta4554-pane-identity

This commit is contained in:
Merge Sim
2026-09-07 12:06:57 -07:00
129 changed files with 7122 additions and 689 deletions
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
image-digest:
description: "Immutable relay image digest (sha256: plus 64 lowercase hex characters)"
description: 'Immutable relay image digest (sha256: plus 64 lowercase hex characters)'
required: true
type: string
regional-placement-mode:
+16 -51
View File
@@ -13325,9 +13325,7 @@
},
{
"file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
"assertions": [
"a refused pointer write drains a delivery parked behind its watermark"
]
"assertions": ["a refused pointer write drains a delivery parked behind its watermark"]
},
{
"file": "src/main/providers/settled-pty-writer-census.test.ts",
@@ -18549,27 +18547,13 @@
"protection": "partial",
"owner": "browser-runtime",
"layer": "electron-packaged",
"surfaces": [
"paired browser placement"
],
"platforms": [
"linux",
"macos",
"windows"
],
"providers": [
"paired-runtime"
],
"coveredPlatforms": [
"linux"
],
"coveredProviders": [
"paired-runtime"
],
"surfaces": ["paired browser placement"],
"platforms": ["linux", "macos", "windows"],
"providers": ["paired-runtime"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Published Linux 1.4.188 desktop against current source in both directions; scheduled weekly and manually runnable. No required PR check.",
"motivatingLinks": [
"https://github.com/stablyai/orca/actions/runs/34069063016"
],
"motivatingLinks": ["https://github.com/stablyai/orca/actions/runs/34069063016"],
"invariant": "A paired client and host without client-hosted browser capabilities retain server-hosted browser placement across supported version skew.",
"oracle": "Require both existing named browser placement scenarios to pass three times with one attempt, zero skips, zero failures, and no report errors.",
"commands": [
@@ -18593,9 +18577,7 @@
},
{
"file": "config/scripts/verify-packaged-browser-participation.test.mjs",
"assertions": [
"reject missing, substituted, skipped and retried scenarios"
]
"assertions": ["reject missing, substituted, skipped and retried scenarios"]
},
{
"file": "config/scripts/packaged-browser-lane-contract.test.mjs",
@@ -18650,26 +18632,13 @@
"protection": "partial",
"owner": "terminal-input",
"layer": "electron-native-ime-e2e",
"surfaces": [
"native Hangul composition",
"Wayland terminal input"
],
"platforms": [
"linux"
],
"providers": [
"local"
],
"coveredPlatforms": [
"linux"
],
"coveredProviders": [
"local"
],
"surfaces": ["native Hangul composition", "Wayland terminal input"],
"platforms": ["linux"],
"providers": ["local"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local"],
"coverageNotes": "Ubuntu 22.04 nested GNOME and IBus Hangul drive three complete native executions in GitHub Actions. GNOME owns IBus; daemon and CLI share its default config discovery path.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/19174"
],
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19174"],
"invariant": "Typing d k 1 Return through native IBus Hangul delivers exactly 아1 followed by newline without missing, duplicate, or reordered characters.",
"oracle": "Three executions each assert three exact UTF-8 PTY lines. Verify the exact Playwright title, zero skips/retries, each individual native composition receipt, and the nested launch Wayland flag.",
"commands": [
@@ -18685,15 +18654,11 @@
"assertionRefs": [
{
"file": "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts",
"assertions": [
"a digit typed right after a Hangul syllable reaches the pty"
]
"assertions": ["a digit typed right after a Hangul syllable reaches the pty"]
},
{
"file": "config/scripts/terminal-ime-e2e-workflow.test.mjs",
"assertions": [
"runs native Wayland independently with CJK fonts and retained evidence"
]
"assertions": ["runs native Wayland independently with CJK fonts and retained evidence"]
}
],
"evidenceRuns": [
+31 -31
View File
@@ -31,12 +31,12 @@ administrators can do about it.
Four independent evidence clusters, from six incidents:
| Cluster | Incidents | Evidence |
| ----------------- | --------- | -------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| Cluster | Incidents | Evidence |
| ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| **Spawn** | all six | `Orca.exe` → `orca-terminal-daemon.exe` → `powershell.exe` / `pwsh.exe` / `cmd.exe` / `reg.exe` → `claude.exe`, `gh.exe`, `codex.cmd` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
Incident E is the one to look at hardest: 5 alerts, 37 evidence items, ATT&CK
**Execution + Collection**, and a description reading _"Screenshots were taken
@@ -143,11 +143,11 @@ PEB fallback to reinstate it — a hooked `ntdll` answering
`STATUS_INVALID_INFO_CLASS` for one target would have flipped a process-wide,
one-way switch back to `PROCESS_VM_READ` on exactly the machines this exists for.
Because the property is the *absence* of an import, it is checkable on the
Because the property is the _absence_ of an import, it is checkable on the
artifact rather than the source: `inspectWindowsProcessTreeAddon()` answers
`clean` / `unpatched` / `missing`, and the rebuild, `ensure-native-runtime.mjs`,
the relay build and `loadWindowsProcessTree()` all key on it. That check is load-
bearing because the published tarball ships a *loadable* prebuilt built from
bearing because the published tarball ships a _loadable_ prebuilt built from
unpatched source, so "it required cleanly" is not evidence.
What to declare to administrators is now one
@@ -180,7 +180,7 @@ Three sites are named in the incident analysis:
`src/shared/setup-agent-sequencing.ts`,
`src/shared/windows-cmd-runner-delayed-launch.ts` and
`src/shared/windows-interactive-login-spawn.ts` each dropped
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script *files*,
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script _files_,
never `-EncodedCommand`. Where the bypass was load-bearing it moved in-payload as
a process-scope `Set-ExecutionPolicy` (`setup-agent-sequencing.ts`), which is the
pattern to copy rather than restoring the switch — the switch loses to a GPO
@@ -192,7 +192,7 @@ What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
`src/main/agent-hooks/runtime-home-hook-command.ts`,
`src/main/agent-hooks/installer-utils.ts`, and `src/main/claude/hook-settings.ts`
— that last one only as a *fallback* since #18875, see below),
— that last one only as a _fallback_ since #18875, see below),
`src/main/runtime/windows-default-route-interfaces.ts`,
`src/main/runtime/orchestration/setup-completion-signal.ts`,
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
@@ -224,7 +224,7 @@ denies the analyser the payload it would otherwise clear.
The hook launcher is prior art worth knowing about. #16003 measured, on a
reporting Kaspersky host, that `-WindowStyle Hidden` paired with
`-EncodedCommand` was denied at `CreateProcess` with exit 126 regardless of
payload — `exit 0` was denied too. The fix was to stop *spelling* the flags:
payload — `exit 0` was denied too. The fix was to stop _spelling_ the flags:
`WINDOWS_POWERSHELL_HOOK_SWITCHES` is now just `-NoProfile`, and separately, in
#16576, the execution policy bypass moved in-payload as a process-scope
`Set-ExecutionPolicy` — a real command-line signal reduction, though #16003's
@@ -247,7 +247,7 @@ a quoted token, each `%` is broken with `"^%"`.
The escaping is not decorative. Measured on Windows 11 against a real `.cmd`
shim, `["a b", 'c"d', "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d',
"e^%F^%g", "h"]` — the `&` truncated the argument *and* ran the remainder as a
"e^%F^%g", "h"]` — the `&` truncated the argument _and_ ran the remainder as a
command.
**How an EDR reads it:** caret escaping is the canonical obfuscation marker in
@@ -259,7 +259,7 @@ obfuscated-command-line detector is tuned on.
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
terminal multiplexer for coding agents *is*. `reg.exe` appears from
terminal multiplexer for coding agents _is_. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
`src/relay/pty-shell-utils.ts` (reading the OpenSSH `DefaultShell`).
@@ -267,7 +267,7 @@ terminal multiplexer for coding agents *is*. `reg.exe` appears from
Nothing here is avoidable in principle. What is controllable is depth and
breadth: every interpreter hop between Orca and the thing the user asked for adds
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
*shorten the interpreter chain* rather than to hide a window.
_shorten the interpreter chain_ rather than to hide a window.
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
@@ -295,7 +295,7 @@ That last clause is the standing assumption of this change, and it is worth
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
any host that is one of the first three. Claude Code itself is a Git Bash host on
native Windows. What no one here has verified is which host a *compat consumer*
native Windows. What no one here has verified is which host a _compat consumer_
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
for exactly that). If one of them spawns hook strings through Windows PowerShell
@@ -318,12 +318,12 @@ then captures the screen through `Graphics.CopyFromScreen`.
That is four separate high-signal behaviours stacked in one process:
| Behaviour | How it is scored |
| ----------------------------------------------- | ---------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
| Behaviour | How it is scored |
| --------------------------------------------- | ------------------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
The bottom two rows are the two the incident text named directly, and they are
also the two a persistent runtime host would remove: a long-lived helper compiles
@@ -381,16 +381,16 @@ changed. Check the code before relying on it.
The checklist. On Windows, do not reach for:
| Don't | Instead |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
| Don't | Instead |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:
@@ -407,7 +407,7 @@ Two framing rules that outlast the table:
This is the single most important operational point, and it is the one most
commonly got wrong. The six incidents are **MDE EDR behavioural alerts**.
Defender Antivirus path exclusions suppress *scan* detections; they do not
Defender Antivirus path exclusions suppress _scan_ detections; they do not
suppress EDR behavioural alerts the same way. Adding
`%LOCALAPPDATA%\Programs\orca\` to the AV exclusion list and expecting the
incidents to stop will not work.
+17 -17
View File
@@ -64,10 +64,10 @@ identity scan opens nothing.
So the module exposes two snapshots, and the row types differ so a cheap caller
cannot read what its flag set did not pay for:
| reader | row type | flags | per-process handles |
| ------------------------------------------ | ---------------------------- | --------------------------- | ------------------- |
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
| reader | row type | flags | per-process handles |
| ------------------------------------------ | --------------------------- | ---------------------- | ------------------- |
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
`Memory` is requested by neither. Nothing reads a working set off this table —
`windows-process-resource-collector.ts` runs its own sweep because it needs
@@ -103,7 +103,7 @@ only under concurrency.
Nothing else in this module prevents that. Each snapshot cache single-flights
only within itself (`inFlight` is a closure per reader), and the wedge set
latches only *after* a read misses its 3 s deadline, so through the healthy
latches only _after_ a read misses its 3 s deadline, so through the healthy
~12 ms of a scan neither excludes the other. Overlap is the normal state rather
than an edge case: other panes keep polling detailed at 750 ms while a teardown
takes identity snapshots, and `codex-structured-turn-processes.ts` issues fresh
@@ -166,15 +166,15 @@ through `toIdentityRow`, so an identity row carries no command line on any host.
### Which callers need which
| caller | reads | flag set |
| --------------------------------------------- | ------------------ | -------- |
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
| caller | reads | flag set |
| ------------------------------------------ | --------------------------------- | -------- |
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
`windows-port-scan.ts` is the one mismatch in the table: it reads only `pid` and
`name`, which the identity set answers, but it calls the detailed reader. On a
@@ -344,7 +344,7 @@ on any other OS keeps using the scan.
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries five changes.
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries six changes.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -368,10 +368,10 @@ on any other OS keeps using the scan.
to Unix ms; a process that denies the handle is emitted with the field
absent, never zero, because callers must be able to tell "cannot identify"
from a timestamp.
5. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
6. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
compiled binary understands, and `lib/index.js` re-exports it.
Why a fifth hunk and not just the enum: unlike `node-pty`, this package
Why a separate hunk and not just the enum: unlike `node-pty`, this package
publishes a prebuilt `.node` at the same `build/Release/` path node-gyp
writes to. pnpm patches the source tree and leaves that prebuilt alone, so a
host can hold a patched `lib/index.js` — `ProcessDataFlag.CreationTime` and
+2 -3
View File
@@ -30,8 +30,7 @@ import { Callout } from '@/components/docs/prose'
[installer](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe)
</li>
<li>
**Linux:**
AppImage
**Linux:** AppImage
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases) ·
@@ -131,7 +130,7 @@ On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `or
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that _during_ startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
+11 -3
View File
@@ -129,19 +129,23 @@ Install Orca and its bundled CLI on the server, then run:
<Callout title="On Linux, start it with orca-ide serve">
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
while it is starting, so that shim can never be the command that starts the server. Read
`orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
[Install → Linux](/docs/install#linux).
while it is starting, so that shim can never be the command that starts the server. Read `orca
serve` as `orca-ide serve` throughout this page when the host is Linux. See [Install →
Linux](/docs/install#linux).
</Callout>
```bash
orca serve --pairing-address <server-tailscale-ip-or-hostname>
# Linux
orca-ide serve --pairing-address <server-tailscale-ip-or-hostname>
```
For example:
```bash
orca serve --pairing-address 100.64.1.20
# Linux
orca-ide serve --pairing-address 100.64.1.20
```
The command:
@@ -157,6 +161,8 @@ Add `--port 6768` when a firewall, tunnel, or service definition requires a fixe
```bash
orca serve --port 6768 --pairing-address 100.64.1.20
# Linux
orca-ide serve --port 6768 --pairing-address 100.64.1.20
```
Use only one host mode at a time. If the Orca desktop app is already sharing that computer, do not start a second `orca serve` process for the same setup.
@@ -167,6 +173,8 @@ For the Orca mobile app, request a mobile-scoped QR code and link:
```bash
orca serve --pairing-address 100.64.1.20 --mobile-pairing
# Linux
orca-ide serve --pairing-address 100.64.1.20 --mobile-pairing
```
Keep the phone on the same tailnet, open Orca Mobile, choose **Pair**, and scan the terminal QR code or paste the printed link.
+406
View File
@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import {
deleteCachedSessionTabStripForHost,
getSessionTabStripCacheKey,
loadCachedSessionTabStrip,
readCachedSessionTabStrip,
resetSessionTabStripCacheForTests,
saveCachedSessionTabStrip
} from './session-tab-strip-cache'
import type { MobileSessionTabStripPreview } from '../session/mobile-session-tab-strip-entries'
const STORAGE_KEY = 'orca:session-tab-strip:v1'
function preview(...ids: string[]): MobileSessionTabStripPreview {
return {
tabs: ids.map((id) => ({ id, type: 'terminal' as const, title: id, agentId: null })),
activeTabId: ids[0] ?? null
}
}
function lastWrittenFile(): { workspaces: { key: string }[] } {
const call = asyncStorage.setItem.mock.calls.at(-1)
return JSON.parse(String(call?.[1]))
}
beforeEach(() => {
vi.useFakeTimers()
asyncStorage.getItem.mockReset().mockResolvedValue(null)
asyncStorage.setItem.mockReset().mockResolvedValue(undefined)
resetSessionTabStripCacheForTests()
})
afterEach(() => {
vi.useRealTimers()
})
describe('getSessionTabStripCacheKey', () => {
it('digests the workspace id so no filesystem path reaches the key', () => {
const path = '/Users/someone/private-client/worktrees/acquisition'
const key = getSessionTabStripCacheKey('host-1', `repo::${path}`)
expect(key).not.toContain(path)
expect(key).not.toContain('someone')
expect(key).toMatch(/^\["host-1","[0-9a-f]{32}"\]$/)
})
it('joins the two ids unambiguously, whatever a worktree path contains', () => {
expect(getSessionTabStripCacheKey('host', 'a\nb')).not.toBe(
getSessionTabStripCacheKey('host\na', 'b')
)
expect(getSessionTabStripCacheKey('host-1', 'wt-1')).not.toBe(
getSessionTabStripCacheKey('host-1', 'wt-2')
)
})
it('needs both a host and a workspace', () => {
expect(getSessionTabStripCacheKey(undefined, 'wt-1')).toBeNull()
expect(getSessionTabStripCacheKey('host-1', undefined)).toBeNull()
})
})
describe('session tab strip cache', () => {
it('serves a save back synchronously and persists it once the write settles', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, preview('tab-1', 'tab-2'))
expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2'])
expect(asyncStorage.setItem).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(300)
expect(asyncStorage.setItem.mock.calls[0]?.[0]).toBe(STORAGE_KEY)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([key])
})
it('reads nothing synchronously before the stored file is loaded', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
asyncStorage.getItem.mockResolvedValue(
JSON.stringify({ workspaces: [{ key, preview: preview('tab-1') }] })
)
expect(readCachedSessionTabStrip(key)).toBeNull()
expect((await loadCachedSessionTabStrip(key))?.tabs.map((tab) => tab.id)).toEqual(['tab-1'])
expect(readCachedSessionTabStrip(key)?.tabs).toHaveLength(1)
})
it('returns null for a workspace with no stored strip', async () => {
expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-9'))).toBeNull()
expect(await loadCachedSessionTabStrip(null)).toBeNull()
})
it('survives unreadable storage', async () => {
asyncStorage.getItem.mockResolvedValue('{not json')
expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-1'))).toBeNull()
})
it('evicts the least recently written workspace past the cap', async () => {
for (let i = 0; i < 14; i++) {
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1'))
}
await vi.advanceTimersByTimeAsync(300)
const keys = lastWrittenFile().workspaces.map((w) => w.key)
expect(keys).toHaveLength(12)
expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-0'))
expect(keys.at(-1)).toBe(getSessionTabStripCacheKey('host-1', 'wt-13'))
})
it('re-writing a workspace makes it the newest, not the oldest', async () => {
for (let i = 0; i < 12; i++) {
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1'))
}
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-0'), preview('tab-2'))
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-99'), preview('tab-1'))
await vi.advanceTimersByTimeAsync(300)
const keys = lastWrittenFile().workspaces.map((w) => w.key)
expect(keys).toContain(getSessionTabStripCacheKey('host-1', 'wt-0'))
expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-1'))
})
it('records a workspace the host has emptied, so a stale strip cannot outlive it', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, preview('tab-1'))
saveCachedSessionTabStrip(key, { tabs: [], activeTabId: null })
expect(readCachedSessionTabStrip(key)).toEqual({ tabs: [], activeTabId: null })
})
it('caps tabs per workspace and title length, and drops an unmatched active id', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
// A file tab, because the titles that survive redaction at all are the ones the cap has
// to bound.
tabs: Array.from({ length: 30 }, (_, i) => ({
id: `tab-${i}`,
type: 'file' as const,
title: 'x'.repeat(200),
agentId: null
})),
activeTabId: 'tab-29'
})
const stored = readCachedSessionTabStrip(key)
expect(stored?.tabs).toHaveLength(24)
expect(stored?.tabs[0]?.title).toHaveLength(64)
expect(stored?.activeTabId).toBeNull()
})
it('drops fields a future tab type might smuggle into storage', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
tabs: [
{
id: 'tab-1',
type: 'file',
title: 'notes.md',
agentId: null,
filePath: '/Users/someone/secret/notes.md'
} as never
],
activeTabId: 'tab-1'
})
await vi.advanceTimersByTimeAsync(300)
expect(String(asyncStorage.setItem.mock.calls.at(-1)?.[1])).not.toContain('/Users/someone')
})
it('drops a stored entry naming a tab type this build cannot draw', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
tabs: [
{ id: 'tab-1', type: 'from-a-newer-build', title: 'raw title', agentId: null } as never,
{ id: 'tab-2', type: 'file', title: 'notes.md', agentId: null }
],
activeTabId: 'tab-2'
})
expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-2'])
})
it('never writes a shell-controlled terminal title, however it arrives', async () => {
const secret = 'psql postgres://admin:hunter2@db.internal/prod'
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
tabs: [
{ id: 'tab-1', type: 'terminal', title: secret, agentId: null },
{ id: 'tab-2', type: 'terminal', title: secret, agentId: 'claude' },
{ id: 'tab-3', type: 'terminal', title: secret, agentId: 'not-a-known-agent' },
{ id: 'tab-4', type: 'browser', title: 'Acme Corp — Q3 layoffs memo', agentId: null }
],
activeTabId: 'tab-1'
})
await vi.advanceTimersByTimeAsync(300)
expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.title)).toEqual([
'Terminal',
'Claude',
'Terminal',
'Browser'
])
const written = String(asyncStorage.setItem.mock.calls.at(-1)?.[1])
expect(written).not.toContain('hunter2')
expect(written).not.toContain('postgres://')
expect(written).not.toContain('layoffs')
})
it('scrubs a stored title written by an older build on the way back out', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
asyncStorage.getItem.mockResolvedValue(
JSON.stringify({
workspaces: [
{
key,
preview: {
tabs: [{ id: 'tab-1', type: 'terminal', title: 'curl -H token', agentId: null }],
activeTabId: 'tab-1'
}
}
]
})
)
expect((await loadCachedSessionTabStrip(key))?.tabs[0]?.title).toBe('Terminal')
})
it('forgets an unpaired host and cannot resurrect it from a later save', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
saveCachedSessionTabStrip(hostB, preview('tab-b'))
await vi.advanceTimersByTimeAsync(300)
await deleteCachedSessionTabStripForHost('host-a')
expect(readCachedSessionTabStrip(hostA)).toBeNull()
expect(readCachedSessionTabStrip(hostB)?.tabs).toHaveLength(1)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
saveCachedSessionTabStrip(hostB, preview('tab-b2'))
await vi.advanceTimersByTimeAsync(300)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
})
it('forgets a host whose rows are only on disk, never read this session', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
asyncStorage.getItem.mockResolvedValue(
JSON.stringify({
workspaces: [
{ key: hostA, preview: preview('tab-a') },
{ key: hostB, preview: preview('tab-b') }
]
})
)
await deleteCachedSessionTabStripForHost('host-a')
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
})
it('drops a pending debounced write so it cannot restore the forgotten host', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await deleteCachedSessionTabStripForHost('host-a')
await vi.advanceTimersByTimeAsync(300)
expect(lastWrittenFile().workspaces).toEqual([])
})
it('rejects a deletion whose write never landed, rather than reporting it as done', async () => {
// A resolved delete over a failed write leaves the forgotten host's tab titles in
// plaintext on disk while every caller believes they are gone.
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
await expect(deleteCachedSessionTabStripForHost('host-a')).rejects.toThrow('storage full')
})
it('keeps a debounced save best effort, so one failed write cannot reject unowned', async () => {
asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-a', 'wt-1'), preview('tab-a'))
// No throw and no unhandled rejection: the write is fire-and-forget by design.
await vi.advanceTimersByTimeAsync(300)
expect(asyncStorage.setItem).toHaveBeenCalledOnce()
})
it('refuses a save for the host it is in the middle of forgetting', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
let releaseWrite!: () => void
asyncStorage.setItem.mockImplementationOnce(
async () =>
new Promise<void>((resolve) => {
releaseWrite = () => resolve()
})
)
const deletion = deleteCachedSessionTabStripForHost('host-a')
// The purge has run and its write is on the wire; a snapshot queued for the
// workspace the user just unpaired now lands in that window.
await vi.advanceTimersByTimeAsync(0)
saveCachedSessionTabStrip(hostA, preview('tab-a2'))
releaseWrite()
await deletion
await vi.advanceTimersByTimeAsync(300)
expect(readCachedSessionTabStrip(hostA)).toBeNull()
expect(lastWrittenFile().workspaces).toEqual([])
})
it('cannot be talked back into a host whose deletion write failed', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
asyncStorage.setItem.mockRejectedValueOnce(new Error('storage full'))
await expect(deleteCachedSessionTabStripForHost('host-a')).rejects.toThrow('storage full')
const writesSoFar = asyncStorage.setItem.mock.calls.length
saveCachedSessionTabStrip(hostA, preview('tab-a3'))
await vi.advanceTimersByTimeAsync(300)
expect(readCachedSessionTabStrip(hostA)).toBeNull()
expect(asyncStorage.setItem).toHaveBeenCalledTimes(writesSoFar)
})
it('lets a debounced write that already snapshotted the removed host land first', async () => {
// The tombstone stops new saves, but a debounced write that fired a moment earlier
// built its blob from the map as it was and is still on the wire. Writing over it
// concurrently leaves which blob lands last up to storage.
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
saveCachedSessionTabStrip(hostB, preview('tab-b'))
let releaseDebounced!: () => void
asyncStorage.setItem.mockImplementationOnce(
async () =>
new Promise<void>((resolve) => {
releaseDebounced = () => resolve()
})
)
await vi.advanceTimersByTimeAsync(300)
const deletion = deleteCachedSessionTabStripForHost('host-a')
await vi.advanceTimersByTimeAsync(0)
expect(asyncStorage.setItem).toHaveBeenCalledOnce()
releaseDebounced()
await deletion
expect(asyncStorage.setItem).toHaveBeenCalledTimes(2)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
})
it('cannot let an older overlapping write commit after the purge', async () => {
// Why: two debounced writes can sit on the bridge at once, and the second used to replace
// the in-flight handle. The purge then awaited only the newer one, so the older blob --
// snapshotted while the forgotten host was still in the map -- could commit last.
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
let stored = ''
const gates: Array<() => void> = []
asyncStorage.setItem.mockImplementation(
(_key: string, value: string) =>
new Promise<void>((resolve) => {
gates.push(() => {
stored = value
resolve()
})
})
)
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
saveCachedSessionTabStrip(hostB, preview('tab-b'))
await vi.advanceTimersByTimeAsync(300)
const deletion = deleteCachedSessionTabStripForHost('host-a')
// Newest released first: only writes that queue behind one another survive this.
for (let step = 0; step < 6 && gates.length > 0; step += 1) {
gates.pop()?.()
await vi.advanceTimersByTimeAsync(0)
}
await deletion
const keys = (JSON.parse(stored) as { workspaces: { key: string }[] }).workspaces.map(
(workspace) => workspace.key
)
expect(keys).toEqual([hostB])
})
})
+260
View File
@@ -0,0 +1,260 @@
// Why: reconnecting to a workspace the phone opened a minute ago tears the session screen back
// to an empty strip and a spinner, even though the tab list it is about to be handed is the one
// it just displayed. Persist the shape of the strip per workspace so a reconnect paints the
// known tabs immediately and swaps in live rows under the same keys.
//
// This file is the authority on what reaches plaintext storage, not its callers: every entry is
// rebuilt field by field on the way in, and shell-controlled titles are replaced with fixed
// labels here rather than trusted to have been scrubbed upstream.
import AsyncStorage from '@react-native-async-storage/async-storage'
import { sha256 } from '@noble/hashes/sha256'
import {
getPersistableTabStripTitle,
isDrawableTabStripType,
type MobileSessionTabStripEntry,
type MobileSessionTabStripPreview
} from '../session/mobile-session-tab-strip-entries'
const STORAGE_KEY = 'orca:session-tab-strip:v1'
// A phone realistically revisits a handful of workspaces; the caps bound both the stored blob
// and the cost of a single write.
const MAX_WORKSPACES = 12
const MAX_TABS_PER_WORKSPACE = 24
const MAX_TITLE_LENGTH = 64
const WRITE_DEBOUNCE_MS = 250
// 128 bits of a digest: far past collision range for a dozen workspaces, and short enough that
// the stored blob stays small.
const WORKSPACE_DIGEST_LENGTH = 32
type StoredWorkspace = { key: string; preview: MobileSessionTabStripPreview }
type StoredFile = { workspaces: StoredWorkspace[] }
// Insertion-ordered, so the first key is the least recently written one to evict.
let memoryCache: Map<string, MobileSessionTabStripPreview> | null = null
let loadPromise: Promise<Map<string, MobileSessionTabStripPreview>> | null = null
let writeTimer: ReturnType<typeof setTimeout> | null = null
// Tail of the write chain. Every write queues behind it, so an older setItem can never
// settle after a newer one and make its stale blob the last word on disk.
let writeInFlight: Promise<void> | null = null
// Hosts forgotten this session. A save racing the deletion would re-insert the host and
// the next debounced write would put its tab titles back on disk, so refuse those saves
// outright. Re-pairing the same host caches again from the next app launch — the cheap
// direction for a deletion the user asked for.
const forgottenHosts = new Set<string>()
/**
* A workspace id ends in a filesystem path, so it is digested rather than stored. The host id
* stays readable because forgetting a host has to be able to find that host's rows, and because
* host ids already key several other entries in this store.
*/
export function getSessionTabStripCacheKey(
hostId: string | undefined,
worktreeId: string | undefined
): string | null {
if (!hostId || !worktreeId) {
return null
}
return JSON.stringify([hostId, digestWorkspaceId(worktreeId)])
}
/** Whatever this process already knows, with no await — so a revisit paints on the first frame. */
export function readCachedSessionTabStrip(key: string | null): MobileSessionTabStripPreview | null {
if (!key || !memoryCache) {
return null
}
return memoryCache.get(key) ?? null
}
export async function loadCachedSessionTabStrip(
key: string | null
): Promise<MobileSessionTabStripPreview | null> {
if (!key) {
return null
}
const cache = await loadFile()
return cache.get(key) ?? null
}
export function saveCachedSessionTabStrip(
key: string | null,
preview: MobileSessionTabStripPreview
): void {
if (!key) {
return
}
const hostId = readHostIdFromKey(key)
if (hostId !== null && forgottenHosts.has(hostId)) {
return
}
const redacted = redactPreview(preview)
const cache = memoryCache ?? new Map()
memoryCache = cache
// Map.set on an existing key keeps its original iteration position, so delete first to make
// the re-inserted key the newest and give the cap true LRU eviction.
cache.delete(key)
cache.set(key, redacted)
while (cache.size > MAX_WORKSPACES) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
cache.delete(oldest)
}
scheduleWrite(cache)
}
/**
* Drop every workspace belonging to a host the user has unpaired. Both the in-memory rows and
* the stored blob have to go: leaving either behind means the next save for any other host
* serializes the forgotten host's tabs straight back to disk.
*/
export async function deleteCachedSessionTabStripForHost(hostId: string): Promise<void> {
// Before the first await: a save landing during the load or the write must not
// re-insert the host the caller is in the middle of forgetting.
forgottenHosts.add(hostId)
// Load first so the rewrite below preserves other hosts. If storage is unreadable we still
// rewrite, which can cost another host its rows — the wrong direction for a cache, the right
// one for a deletion the user asked for.
const cache = await loadFile()
// Deleting the entry the iterator is standing on is well-defined for a Map.
for (const key of cache.keys()) {
if (readHostIdFromKey(key) === hostId) {
cache.delete(key)
}
}
if (writeTimer) {
clearTimeout(writeTimer)
writeTimer = null
}
// Queued, not raced: the purge is the last write, and its failure is the caller's.
await enqueueWrite(cache)
}
export function resetSessionTabStripCacheForTests(): void {
if (writeTimer) {
clearTimeout(writeTimer)
writeTimer = null
}
memoryCache = null
loadPromise = null
writeInFlight = null
forgottenHosts.clear()
}
function digestWorkspaceId(worktreeId: string): string {
const digest = sha256(new TextEncoder().encode(worktreeId))
let hex = ''
for (const byte of digest) {
hex += byte.toString(16).padStart(2, '0')
}
return hex.slice(0, WORKSPACE_DIGEST_LENGTH)
}
function readHostIdFromKey(key: string): string | null {
try {
const parsed = JSON.parse(key) as unknown
return Array.isArray(parsed) && typeof parsed[0] === 'string' ? parsed[0] : null
} catch {
return null
}
}
async function loadFile(): Promise<Map<string, MobileSessionTabStripPreview>> {
if (memoryCache) {
return memoryCache
}
loadPromise ??= (async () => {
const parsed = await readStoredFile()
// A save that landed while the read was in flight owns the newer truth.
const cache = memoryCache ?? new Map<string, MobileSessionTabStripPreview>()
for (const workspace of parsed) {
if (!cache.has(workspace.key)) {
cache.set(workspace.key, workspace.preview)
}
}
memoryCache = cache
return cache
})()
return loadPromise
}
async function readStoredFile(): Promise<StoredWorkspace[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw) as StoredFile
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.workspaces)) {
return []
}
return parsed.workspaces.flatMap((workspace) => {
if (typeof workspace?.key !== 'string' || !Array.isArray(workspace.preview?.tabs)) {
return []
}
return [{ key: workspace.key, preview: redactPreview(workspace.preview) }]
})
} catch {
return []
}
}
// Why: a flurry of snapshots (one per desktop republication) must not hammer AsyncStorage.
function scheduleWrite(cache: Map<string, MobileSessionTabStripPreview>): void {
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
// Best effort by design: a dropped cache refresh costs one repaint, and the next
// save rewrites the whole map. Only the deletion path needs the failure.
void enqueueWrite(cache).catch(() => {})
}, WRITE_DEBOUNCE_MS)
}
// Why the chain rather than one handle: two debounced writes can overlap on the bridge, and
// the second overwrote the handle. A deletion then awaited only the newer one, so the older
// write -- serialized before the purge, host rows and all -- could land last and restore them.
function enqueueWrite(cache: Map<string, MobileSessionTabStripPreview>): Promise<void> {
const queued = (writeInFlight ?? Promise.resolve()).then(() => writeFile(cache))
// A rejected link must not break the chain for the writes queued behind it.
writeInFlight = queued.catch(() => {})
return queued
}
async function writeFile(cache: Map<string, MobileSessionTabStripPreview>): Promise<void> {
const workspaces: StoredWorkspace[] = [...cache].map(([key, preview]) => ({ key, preview }))
// Throws on purpose: a deletion that only removed the in-memory rows must not be
// reported as a deletion, or the forgotten host's titles stay in plaintext on disk.
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces }))
}
// Rebuilt field by field so a field later added to the live tab type cannot ride into storage
// without someone deciding it belongs there.
function redactPreview(preview: MobileSessionTabStripPreview): MobileSessionTabStripPreview {
const tabs: MobileSessionTabStripEntry[] = []
for (const tab of preview.tabs ?? []) {
if (typeof tab?.id !== 'string' || !isDrawableTabStripType(tab.type)) {
continue
}
const agentId = typeof tab.agentId === 'string' ? tab.agentId : null
const title = typeof tab.title === 'string' ? tab.title : ''
tabs.push({
id: tab.id,
type: tab.type,
title: getPersistableTabStripTitle({ type: tab.type, title, agentId }).slice(
0,
MAX_TITLE_LENGTH
),
agentId
})
if (tabs.length === MAX_TABS_PER_WORKSPACE) {
break
}
}
const activeTabId =
typeof preview.activeTabId === 'string' && tabs.some((tab) => tab.id === preview.activeTabId)
? preview.activeTabId
: null
return { tabs, activeTabId }
}
@@ -1,8 +1,36 @@
import { describe, expect, it } from 'vitest'
import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
import type { ConnectionLogEntry } from '../transport/types'
const NOW = Date.UTC(2026, 6, 9, 22, 0, 0)
function stageEntry(
id: string,
ts: number,
name: string,
ms: number,
complete: boolean
): ConnectionLogEntry {
return {
id,
ts,
level: complete ? 'info' : 'warn',
path: 'relay',
message: `Relay dial stage ${name} ${complete ? 'finished' : 'did not finish'}`,
timing: { kind: 'relay-dial-stage', name, ms, complete }
}
}
function stateEntry(id: string, ts: number, name: string, ms: number): ConnectionLogEntry {
return {
id,
ts,
level: 'info',
message: `Connection state ${name} → connected`,
timing: { kind: 'connection-state', name, ms, complete: true }
}
}
describe('buildConnectionDiagnosticsReport', () => {
it('summarizes a failing Tailscale host with its log', () => {
const report = buildConnectionDiagnosticsReport({
@@ -68,13 +96,28 @@ describe('buildConnectionDiagnosticsReport', () => {
activePath: 'tailscale',
pendingPath: 'relay',
entries: [
{
id: 'relay-stage-opening',
ts: NOW - 6_000,
level: 'info',
path: 'relay',
message: 'Relay dial stage opening finished',
detail: '118ms — resumeToken=secret-resume-token',
timing: { kind: 'relay-dial-stage', name: 'opening', ms: 118, complete: true }
},
{
id: 'relay-failure',
ts: NOW - 5_000,
level: 'error',
message: 'Relay: relay dial failed',
detail:
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token',
timing: {
kind: 'relay-dial-stage',
name: 'awaiting-hello',
ms: 9_100,
complete: false
}
}
],
nowMs: NOW
@@ -87,6 +130,9 @@ describe('buildConnectionDiagnosticsReport', () => {
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
expect(report).toContain('resumeToken=[redacted]')
expect(report).not.toContain('secret-resume-token')
expect(report).toContain(
'Relay dial stages: opening 118ms · awaiting-hello 9.1s (did not finish) — total 9.2s'
)
})
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
@@ -116,6 +162,52 @@ describe('buildConnectionDiagnosticsReport', () => {
expect(report).not.toContain('bearer-secret')
})
it('breaks a slow connect down by dial stage and connection state', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 6',
endpoint: 'ws://192.168.1.50:6768',
state: 'connected',
reconnectAttempts: 2,
lastConnectedAt: NOW,
platform: 'ios 26.5.1',
appVersion: '0.0.47',
entries: [
stageEntry('a1', NOW - 30_000, 'opening', 90, false),
stateEntry('s1', NOW - 29_000, 'connecting', 12_000),
stageEntry('b1', NOW - 20_000, 'opening', 120, true),
stageEntry('b2', NOW - 19_000, 'awaiting-hello', 6_400, true),
stageEntry('b3', NOW - 13_000, 'handshaking', 240, true),
stageEntry('b4', NOW - 12_000, 'confirming', 1_180, true),
stateEntry('s2', NOW - 11_000, 'connecting', 8_000)
],
nowMs: NOW
})
// Only the latest dial is broken out, so a reconnect loop cannot average away
// the attempt the reporter is complaining about.
expect(report).toContain(
'Relay dial stages (latest of 2): opening 120ms · awaiting-hello 6.4s · handshaking 240ms · confirming 1.2s — total 7.9s'
)
expect(report).toContain('Connection state dwell: connecting 20.0s ×2')
})
it('omits the timing lines when nothing recorded a phase duration', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 7',
endpoint: 'ws://192.168.1.50:6768',
state: 'connected',
reconnectAttempts: 0,
lastConnectedAt: NOW,
platform: 'ios 26.5.1',
appVersion: '0.0.47',
entries: [{ id: 'plain', ts: NOW, level: 'info', message: 'Authenticated' }],
nowMs: NOW
})
expect(report).not.toContain('Relay dial stages')
expect(report).not.toContain('Connection state dwell')
})
it('bounds a single event line before submission while preserving its identity', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 5',
@@ -8,6 +8,7 @@ import { normalizeHostAppVersion } from '../transport/host-app-version-store'
import { formatEndpoint } from './host-reachability'
import { diagnoseConnection } from './connection-diagnostics-analysis'
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
import { summarizeConnectionLogTimings } from './connection-log-timing-summary'
const MAX_EVENT_LINE_BYTES = 2 * 1024
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
@@ -59,6 +60,7 @@ export function buildConnectionDiagnosticsReport(args: {
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push(...summarizeConnectionLogTimings(entries))
lines.push('')
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
lines.push(`Next step: ${diagnosis.nextStep}`)
@@ -0,0 +1,66 @@
import type { ConnectionLogEntry, ConnectionLogTiming } from '../transport/types'
// Why: a report that only says "connecting for 10s" cannot be triaged. These lines
// turn the per-phase timings the transport now records into the two questions
// support actually asks: which relay dial stage ate the time, and how long the
// client sat in each connection state.
export function summarizeConnectionLogTimings(entries: readonly ConnectionLogEntry[]): string[] {
const timings = entries.flatMap((entry) => (entry.timing ? [entry.timing] : []))
const lines: string[] = []
const dials = groupRelayDials(timings.filter((timing) => timing.kind === 'relay-dial-stage'))
const latestDial = dials.at(-1)
if (latestDial) {
const label =
dials.length > 1 ? `Relay dial stages (latest of ${dials.length})` : 'Relay dial stages'
const total = latestDial.reduce((sum, timing) => sum + timing.ms, 0)
lines.push(
`${label}: ${latestDial.map(formatStageTiming).join(' · ')} — total ${formatDurationMs(total)}`
)
}
const states = totalPerName(timings.filter((timing) => timing.kind === 'connection-state'))
if (states.length > 0) {
lines.push(
`Connection state dwell: ${states
.map(
({ name, ms, count }) => `${name} ${formatDurationMs(ms)}${count > 1 ? ` ×${count}` : ''}`
)
.join(' · ')}`
)
}
return lines
}
// Relay dial stages are strictly ordered and every dial starts in 'opening', so an
// 'opening' timing opens a new group. Reporting only the latest keeps a reconnect
// loop from averaging away the attempt the reporter is complaining about.
function groupRelayDials(timings: readonly ConnectionLogTiming[]): ConnectionLogTiming[][] {
const dials: ConnectionLogTiming[][] = []
for (const timing of timings) {
if (timing.name === 'opening' || dials.length === 0) {
dials.push([])
}
dials.at(-1)!.push(timing)
}
return dials
}
function totalPerName(
timings: readonly ConnectionLogTiming[]
): { name: string; ms: number; count: number }[] {
const totals = new Map<string, { name: string; ms: number; count: number }>()
for (const timing of timings) {
const total = totals.get(timing.name) ?? { name: timing.name, ms: 0, count: 0 }
total.ms += timing.ms
total.count += 1
totals.set(timing.name, total)
}
return [...totals.values()]
}
function formatStageTiming(timing: ConnectionLogTiming): string {
return `${timing.name} ${formatDurationMs(timing.ms)}${timing.complete ? '' : ' (did not finish)'}`
}
function formatDurationMs(ms: number): string {
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`
}
@@ -76,9 +76,10 @@ export function MobileSessionActiveContent({
activePendingTerminalTab,
isPendingTerminalRecoveryParked,
retryPendingTerminalRecovery,
reconnectViewState,
tabStripRows,
showLoadingState,
measurePrewarmViewport,
visibleTabs,
showEmptyState,
keyboardLift,
activeTerminalKeyboardLift,
@@ -87,14 +88,20 @@ export function MobileSessionActiveContent({
} = controller
// Why the same list the header gates on: an unmounted tab bar gives the content row its band
// back, so the pre-warm would measure a taller box than the pane ever gets. Reading the header's
// own condition keeps the two from drifting when what counts as a visible tab changes.
const prewarmReservedTabBarHeight = visibleTabs.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT
return showLoadingState ? (
// Why: the engine boots inside the real terminal frame while the startup RPCs are still in
// flight, so the first pane inherits a warm WebView and a measured viewport (see prewarm).
// own rows (live or cached preview) keeps the two from drifting.
const prewarmReservedTabBarHeight = tabStripRows.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT
// Why: the cached strip in the header is the content during a reconnect; the terminal body
// cannot be, because replaying stored scrollback into the WebView would double-render once the
// live stream replays the same rows. See mobile-session-reconnect-view-state. The engine still
// boots inside the real terminal frame while the startup RPCs are in flight, so the first pane
// inherits a warm WebView and a measured viewport (see prewarm).
return reconnectViewState.kind === 'reconnecting-with-cache' || showLoadingState ? (
<View style={styles.terminalFrame}>
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
{reconnectViewState.kind === 'reconnecting-with-cache' ? (
<Text style={styles.emptyText}>{reconnectViewState.label}</Text>
) : null}
</View>
<TerminalEnginePrewarm
reservedTabBarHeight={prewarmReservedTabBarHeight}
+30 -29
View File
@@ -14,10 +14,6 @@ import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
import { triggerMediumImpact } from '../platform/haptics'
import { StatusDot } from '../components/StatusDot'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
import { colors } from '../theme/mobile-theme'
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
import { styles } from './mobile-session-styles'
@@ -32,7 +28,6 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
forceReconnectHost,
worktreeName,
activePanel,
activeSessionTabId,
activeSessionTabIdRef,
tabStripRef,
tabStripOffsetRef,
@@ -52,7 +47,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
scrollActiveTabIntoView,
switchSessionTab,
openSessionTabActionSheetAfterKeyboardDismiss,
visibleTabs,
tabStripRows,
showConnectionRetry,
terminalSummary,
handlePanelTap,
@@ -117,7 +112,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
) : null}
</View>
{visibleTabs.length > 0 && (
{tabStripRows.length > 0 && (
<View style={styles.tabBar}>
{/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */}
<ScrollView
@@ -140,45 +135,51 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
>
{visibleTabs.map((t) => (
{tabStripRows.map(({ entry, isActive, tab }) => (
<Pressable
key={t.id}
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
key={entry.id}
style={[
styles.tab,
isActive && styles.tabActive,
tab === null && styles.tabPreview
]}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(t.id, { x, width })
if (t.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(t.id, false)
tabLayoutsRef.current.set(entry.id, { x, width })
if (entry.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(entry.id, false)
}
}}
onPress={() => switchSessionTab(t)}
onLongPress={() => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(t)
}}
// A cached preview row has no live tab behind it, so both gestures need the
// reconnect to land first.
disabled={tab === null}
onPress={tab === null ? undefined : () => switchSessionTab(tab)}
onLongPress={
tab === null
? undefined
: () => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(tab)
}
}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
{t.type === 'browser' && (
{entry.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'markdown' && (
{entry.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'file' && (
{entry.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? <MobileAgentIcon agentId={agentId} size={13} /> : null
})()}
{entry.agentId !== null && <MobileAgentIcon agentId={entry.agentId} size={13} />}
<Text
style={[styles.tabText, t.id === activeSessionTabId && styles.tabTextActive]}
style={[styles.tabText, isActive && styles.tabTextActive]}
numberOfLines={1}
>
{getMobileSessionTabTitle(t)}
{entry.title}
</Text>
</View>
</Pressable>
@@ -116,6 +116,11 @@ export const mobileSessionFrameStyles = StyleSheet.create({
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
// Why: a cached row is inert until the reconnect lands, so it carries the same de-emphasis as
// the disabled tab-bar buttons beside it rather than passing for a live tab.
tabPreview: {
opacity: 0.45
},
tabActive: {
// Neutral grey underline, matching the desktop terminal tab's active
// indicator (a muted foreground/card mix), not a blue accent.
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import { selectMobileSessionReconnectViewState } from './mobile-session-reconnect-view-state'
import {
getMobileSessionTabStripRows,
toMobileSessionTabStripPreview,
type MobileSessionTabStripPreview
} from './mobile-session-tab-strip-entries'
import type { MobileSessionTab } from './mobile-session-route-types'
function terminalTab(id: string, title: string, isActive = false): MobileSessionTab {
return { type: 'terminal', id, title, terminal: `h-${id}`, isActive }
}
const cachedPreview: MobileSessionTabStripPreview = {
tabs: [
{ id: 'tab-1', type: 'terminal', title: 'claude', agentId: 'claude' },
{ id: 'tab-2', type: 'terminal', title: 'shell', agentId: null }
],
activeTabId: 'tab-1'
}
const base = {
connState: 'reconnecting',
verdictKind: 'normal',
terminalsLoaded: false,
liveTabCount: 0,
activeHandle: null,
cachedPreview: null
} as const
describe('selectMobileSessionReconnectViewState', () => {
it('renders the cached strip with a progress label while reconnecting', () => {
const state = selectMobileSessionReconnectViewState({ ...base, cachedPreview })
expect(state).toEqual({
kind: 'reconnecting-with-cache',
preview: cachedPreview,
label: 'Reconnecting…'
})
})
it('labels the post-connect hydration gap as loading, not reconnecting', () => {
const state = selectMobileSessionReconnectViewState({
...base,
connState: 'connected',
cachedPreview
})
expect(state.kind === 'reconnecting-with-cache' && state.label).toBe('Loading tabs…')
})
it('blocks when nothing is cached for this workspace', () => {
expect(selectMobileSessionReconnectViewState(base)).toEqual({ kind: 'blocking' })
expect(
selectMobileSessionReconnectViewState({
...base,
cachedPreview: { tabs: [], activeTabId: null }
})
).toEqual({ kind: 'blocking' })
})
it('keeps mounted live content instead of swapping in its own cached snapshot', () => {
expect(
selectMobileSessionReconnectViewState({ ...base, liveTabCount: 2, cachedPreview })
).toEqual({ kind: 'live' })
expect(
selectMobileSessionReconnectViewState({ ...base, activeHandle: 'h-1', cachedPreview })
).toEqual({ kind: 'live' })
})
it('treats a host-confirmed empty workspace as live', () => {
expect(
selectMobileSessionReconnectViewState({
...base,
connState: 'connected',
terminalsLoaded: true,
cachedPreview
})
).toEqual({ kind: 'live' })
})
it('falls back to the offline state once the retry loop or the pairing has failed', () => {
expect(
selectMobileSessionReconnectViewState({ ...base, verdictKind: 'unreachable', cachedPreview })
).toEqual({ kind: 'offline' })
expect(
selectMobileSessionReconnectViewState({ ...base, verdictKind: 'auth-failed', cachedPreview })
).toEqual({ kind: 'offline' })
})
it('keeps showing the cache through a transient warning verdict', () => {
expect(
selectMobileSessionReconnectViewState({ ...base, verdictKind: 'warning', cachedPreview }).kind
).toBe('reconnecting-with-cache')
})
})
describe('getMobileSessionTabStripRows', () => {
it('draws disabled preview rows while reconnecting, then the live tabs under the same keys', () => {
const preview = selectMobileSessionReconnectViewState({ ...base, cachedPreview })
const previewRows = getMobileSessionTabStripRows({
liveTabs: [],
activeSessionTabId: null,
preview: preview.kind === 'reconnecting-with-cache' ? preview.preview : null
})
expect(previewRows.map((row) => row.entry.id)).toEqual(['tab-1', 'tab-2'])
expect(previewRows.map((row) => row.tab)).toEqual([null, null])
expect(previewRows.map((row) => row.isActive)).toEqual([true, false])
const liveTabs = [terminalTab('tab-1', 'claude', true), terminalTab('tab-2', 'shell')]
const liveRows = getMobileSessionTabStripRows({
liveTabs,
activeSessionTabId: 'tab-1',
preview: null
})
expect(liveRows.map((row) => row.entry.id)).toEqual(previewRows.map((row) => row.entry.id))
expect(liveRows.map((row) => row.isActive)).toEqual(previewRows.map((row) => row.isActive))
expect(liveRows.every((row) => row.tab !== null)).toBe(true)
})
it('prefers live tabs over a preview that is still present', () => {
const rows = getMobileSessionTabStripRows({
liveTabs: [terminalTab('tab-9', 'fresh', true)],
activeSessionTabId: 'tab-9',
preview: cachedPreview
})
expect(rows.map((row) => row.entry.id)).toEqual(['tab-9'])
})
it('keeps only the drawn fields when projecting a preview to persist', () => {
const preview = toMobileSessionTabStripPreview(
[
{
type: 'terminal',
id: 'tab-1',
title: 'claude',
terminal: 'h-1',
launchAgent: 'claude',
launchDraft: 'unsent secret prompt',
isActive: true
}
],
'tab-1'
)
expect(preview).toEqual({
tabs: [{ id: 'tab-1', type: 'terminal', title: 'claude', agentId: 'claude' }],
activeTabId: 'tab-1'
})
expect(JSON.stringify(preview)).not.toContain('unsent secret prompt')
})
})
@@ -0,0 +1,61 @@
import type { ConnectionVerdict } from '../transport/connection-health'
import type { ConnectionState } from '../transport/types'
import type { MobileSessionTabStripPreview } from './mobile-session-tab-strip-entries'
/**
* What the session screen should draw while the phone is not yet serving live tabs.
*
* - `live`: real tabs are mounted (or the host has confirmed there are none). The existing
* loading/empty/content branches own the screen.
* - `reconnecting-with-cache`: nothing live yet, but this workspace's last strip is on the
* device. Draw it, disabled, with a compact progress line instead of a bare spinner.
* - `offline`: the retry loop has given up or the pairing is rejected. A stale strip would
* imply a session we cannot reach, so fall back to the existing offline affordance.
* - `blocking`: nothing live and nothing cached. Unchanged from before this state existed.
*/
export type MobileSessionReconnectViewState =
| { kind: 'live' }
| { kind: 'reconnecting-with-cache'; preview: MobileSessionTabStripPreview; label: string }
| { kind: 'offline' }
| { kind: 'blocking' }
export function selectMobileSessionReconnectViewState(args: {
connState: ConnectionState
verdictKind: ConnectionVerdict['kind']
terminalsLoaded: boolean
liveTabCount: number
activeHandle: string | null
cachedPreview: MobileSessionTabStripPreview | null
}): MobileSessionReconnectViewState {
const { connState, verdictKind, terminalsLoaded, liveTabCount, activeHandle, cachedPreview } =
args
// A mounted terminal or tab is the real thing; a mid-session drop must never trade it for a
// snapshot of itself, however the connection is faring.
if (liveTabCount > 0 || activeHandle !== null) {
return { kind: 'live' }
}
// The host has answered and said this workspace is empty — that is live truth, not a gap.
if (connState === 'connected' && terminalsLoaded) {
return { kind: 'live' }
}
if (verdictKind === 'unreachable' || verdictKind === 'auth-failed') {
return { kind: 'offline' }
}
if (cachedPreview && cachedPreview.tabs.length > 0) {
return {
kind: 'reconnecting-with-cache',
preview: cachedPreview,
label: reconnectProgressLabel(connState)
}
}
return { kind: 'blocking' }
}
function reconnectProgressLabel(connState: ConnectionState): string {
if (connState === 'connected') {
return 'Loading tabs…'
}
return connState === 'reconnecting' || connState === 'disconnected'
? 'Reconnecting…'
: 'Connecting…'
}
@@ -37,6 +37,7 @@ const LOGIC_EXPANSION_NAMES = new Set([
'useMobileSessionContentCreateActions',
'useMobileSessionCloseActions',
'useMobileSessionBulkClose',
'useMobileSessionTabStripCache',
'useMobileSessionPresentation',
'useMobileSessionPanelRouteActions'
])
@@ -62,12 +63,12 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = '32f0d40d90a76d381480b32f7e8a42b209fa6d6740def39e8691c8fc4dce1871'
const HEAD_HOOK_BINDING_SHA256 = '0f4fac965d009b93e7d0e128ddcbc650f1e83b7adb8c0e3c91d0710e3a8c8ccc'
const HEAD_MAIN_HOOK_SHA256 = 'e22e7d3a1147ef19c747f0e216b778e794a73e027e96cf84fac2dde03b37b640'
const HEAD_HOOK_BINDING_SHA256 = '531fe06cf2c261b1346bbc949c9ceba5aea8b8ace2dcb8a1898e9759745e013c'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'e5df1043256bcb0b3813bf89161d91f5e65c00749fbb6d98176bca82e878d061'
const HEAD_CALLBACK_BODY_SHA256 = '6d9ed614ed139aef5cc911c33ea4220cc1fc5f888a1a564ef85e6910cc118bc3'
const HEAD_EFFECT_SHA256 = 'cf697133278832d33ecf9b87c1c2b1059091d238bad3ca6bed6032f8cf19ad7e'
const HEAD_EFFECT_SHA256 = 'a6d4d5cb573926f40faa7701cef7885a0f2c7e7c5cfaa91f4e480c29aba44d79'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'0e553eb5ec7aeda8f8336b8da85ff87eb3657a21fa32d3c75c9cc32e36860244'
@@ -79,11 +80,11 @@ const HEAD_TIMER_CREATION_SHA256 =
'36c3ccef371698e25cd2eb239df7a8dea6dcc674d9da43cc38cabfa3a8f64929'
const HEAD_TIMER_CLEANUP_SHA256 = '2f41ddc30d0e9c1b6d1d6b5e09d96d1b3facd3133acae1ff7436bb40e4ef39dc'
const HEAD_RUNTIME_STRING_SHA256 =
'f0e63142c8452bfd633eda1f42e73c718e3f4baf703d31d260e03b8048fd8527'
const HEAD_HOST_JSX_SHA256 = '37e6ad7ca6406a4d23ac85c347ca210235b434fd7c2578cffdfe58336221fbb4'
const HEAD_LEAF_JSX_SHA256 = '9e8faf5df0c6a792beb74c6608bce32ba872fd48becc0a4b6aea4b5a5bbbbeda'
'694a22ed924ebc2a7d380089ff2cfd3e27f5d72d3c4d4b7b06aa3006db93c053'
const HEAD_HOST_JSX_SHA256 = '0aca9fe4b6738228020fe20334fe2716471a2fdf57e4feea6a2a92cac1c04c58'
const HEAD_LEAF_JSX_SHA256 = '2c38e19ffbcaae14f9df2fdb44751546d2b936f9a4b2c5e90727a5f74f3c2665'
const HEAD_STYLE_REFERENCE_SHA256 =
'4a71a8620d825975375cdfe402424e612a987ba867042aa1701993ef9d0d6208'
'da81d6065c5c1ebafbbd721321023cddd0bfc1afa0325749f736bb97898f9556'
const HEAD_IDENTITY_FIELD_SHA256 =
'a7444b7d0953edb34abc77180ba11d458b02081547b8499249571efd30ac0609'
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
@@ -474,13 +475,13 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(269)
expect(main.hooks).toHaveLength(272)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(78)
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
expect(main.effects).toHaveLength(25)
expect(main.effects).toHaveLength(27)
expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256)
expect(contentBindings).toHaveLength(14)
expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256)
@@ -522,14 +523,14 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(543)
expect(strings).toHaveLength(545)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(126)
expect(jsx.host).toHaveLength(127)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
expect(jsx.leaf).toHaveLength(63)
expect(jsx.leaf).toHaveLength(62)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(174)
expect(jsx.styleReferences).toHaveLength(176)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
})
})
@@ -33,6 +33,7 @@ export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [
'./use-mobile-session-content-create-actions.ts',
'./use-mobile-session-close-actions.ts',
'./use-mobile-session-bulk-close.ts',
'./use-mobile-session-tab-strip-cache.ts',
'./use-mobile-session-presentation.ts',
'./use-mobile-session-panel-route-actions.tsx',
'./MobileSessionMarkdownReader.tsx',
@@ -336,7 +336,7 @@ describe('mobile session startup', () => {
// Why: the loading and pending-terminal states are exactly the window in which the startup
// RPCs are outstanding, so the engine loads there rather than after terminal.list answers.
const loadingBranch = sliceBetween(
'return showLoadingState ? (',
"return reconnectViewState.kind === 'reconnecting-with-cache' || showLoadingState ? (",
') : showEmptyState ? (',
activeContentSource
)
@@ -0,0 +1,116 @@
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import type { MobileSessionTab, MobileSessionTabType } from './mobile-session-route-types'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
/**
* The only session-tab fields the tab strip draws. Everything else the live tab carries (unsent
* launch drafts, absolute file paths, browser URLs, agent session ids) stays on the wire.
*/
export type MobileSessionTabStripEntry = {
id: string
type: MobileSessionTabType
title: string
agentId: string | null
}
export type MobileSessionTabStripPreview = {
tabs: readonly MobileSessionTabStripEntry[]
activeTabId: string | null
}
export type MobileSessionTabStripRow = {
entry: MobileSessionTabStripEntry
isActive: boolean
/** null on a preview row: switching to that tab needs a live connection. */
tab: MobileSessionTab | null
}
export function toMobileSessionTabStripEntry(tab: MobileSessionTab): MobileSessionTabStripEntry {
return {
id: tab.id,
type: tab.type,
title: getMobileSessionTabTitle(tab),
agentId:
tab.type === 'agent-session'
? tab.agent
: tab.type === 'terminal'
? resolveMobileTerminalTabAgentId(tab)
: null
}
}
/**
* Every tab type the strip knows how to draw. A stored entry naming anything else is dropped
* rather than trusted, so a type added later fails closed: its rows go missing from the preview
* instead of carrying an unreviewed title into storage.
*/
const drawableTabTypes = new Set<string>([
'terminal',
'markdown',
'file',
'browser',
'agent-session'
] satisfies readonly MobileSessionTabType[])
export function isDrawableTabStripType(type: string): type is MobileSessionTabType {
return drawableTabTypes.has(type)
}
const agentDisplayNames: Readonly<Record<string, string>> = TUI_AGENT_DISPLAY_NAMES
/**
* The title a strip entry may be written to disk under.
*
* A terminal's title is whatever the shell last set, which is routinely the command line —
* `psql postgres://user:password@host/db`, `curl -H "Authorization: Bearer ..."`. None of that
* belongs in plaintext storage, and a browser tab's page title is no better. Both collapse to a
* fixed label, so what survives is the shape of the strip, not its contents. A resolved agent
* still names itself, because that lookup is a closed enum: an unrecognised id yields the
* generic label rather than passing text through.
*/
export function getPersistableTabStripTitle(
entry: Pick<MobileSessionTabStripEntry, 'type' | 'title' | 'agentId'>
): string {
if (entry.type === 'terminal') {
const agentLabel = entry.agentId === null ? undefined : agentDisplayNames[entry.agentId]
return agentLabel ?? 'Terminal'
}
if (entry.type === 'browser') {
return 'Browser'
}
return entry.title
}
export function toMobileSessionTabStripPreview(
tabs: readonly MobileSessionTab[],
activeTabId: string | null
): MobileSessionTabStripPreview {
return { tabs: tabs.map(toMobileSessionTabStripEntry), activeTabId }
}
/**
* Rows for the header strip. Live tabs always win; the preview only fills a strip that has no
* live rows yet, and its ids are the live ids, so the swap reuses the same React keys.
*/
export function getMobileSessionTabStripRows(args: {
liveTabs: readonly MobileSessionTab[]
activeSessionTabId: string | null
preview: MobileSessionTabStripPreview | null
}): MobileSessionTabStripRow[] {
const { liveTabs, activeSessionTabId, preview } = args
if (liveTabs.length > 0 || !preview) {
return liveTabs.map((tab) => ({
entry: toMobileSessionTabStripEntry(tab),
isActive: tab.id === activeSessionTabId,
tab
}))
}
return preview.tabs.map((entry) => ({
entry,
isActive: entry.id === preview.activeTabId,
tab: null
}))
}
@@ -120,12 +120,12 @@ describe('terminal pre-warm frame geometry', () => {
it('mounts the tab bar only once a tab is visible, which is what shortens the pane', () => {
expect(headerSource).toContain(
'{visibleTabs.length > 0 && (\n <View style={styles.tabBar}>'
'{tabStripRows.length > 0 && (\n <View style={styles.tabBar}>'
)
// So the reservation has to be the exact complement of that condition, read off the same list
// the header gates on rather than a proxy for it.
// So the reservation has to be the exact complement of that condition, read off the same rows
// the header gates on (live tabs or the cached reconnect preview) rather than a proxy for it.
expect(activeContentSource).toContain(
'const prewarmReservedTabBarHeight = visibleTabs.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT'
'const prewarmReservedTabBarHeight = tabStripRows.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT'
)
})
@@ -27,6 +27,7 @@ import { useMobileSessionTerminalCreateActions } from './use-mobile-session-term
import { useMobileSessionContentCreateActions } from './use-mobile-session-content-create-actions'
import { useMobileSessionCloseActions } from './use-mobile-session-close-actions'
import { useMobileSessionBulkClose } from './use-mobile-session-bulk-close'
import { useMobileSessionTabStripCache } from './use-mobile-session-tab-strip-cache'
import { useMobileSessionPresentation } from './use-mobile-session-presentation'
import { useMobileSessionPanelRouteActions } from './use-mobile-session-panel-route-actions'
@@ -113,7 +114,8 @@ export function useMobileSessionController() {
useMobileSessionCloseActions(contentCreateActions)
)
const bulkClose = Object.assign(closeActions, useMobileSessionBulkClose(closeActions))
const presentation = Object.assign(bulkClose, useMobileSessionPresentation(bulkClose))
const tabStripCache = Object.assign(bulkClose, useMobileSessionTabStripCache(bulkClose))
const presentation = Object.assign(tabStripCache, useMobileSessionPresentation(tabStripCache))
const panelRouteActions = Object.assign(
presentation,
useMobileSessionPanelRouteActions(presentation)
@@ -3,9 +3,11 @@ import { classifyConnection, verdictDisplayLabel } from '../transport/connection
import { computeActiveTerminalKeyboardLift } from '../terminal/terminal-keyboard-avoidance-lift'
import { useInitialSessionTerminalAutoCreate } from './use-initial-session-terminal-autocreate'
import { MOBILE_SESSION_STATUS_LABELS } from './mobile-session-route-helpers'
import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close'
import { selectMobileSessionReconnectViewState } from './mobile-session-reconnect-view-state'
import { getMobileSessionTabStripRows } from './mobile-session-tab-strip-entries'
import type { MobileSessionTabStripCacheModel } from './use-mobile-session-tab-strip-cache'
export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) {
export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheModel) {
const {
created,
worktreeId,
@@ -24,6 +26,8 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
terminalKeyboardMetrics,
toastOpacityRef,
hostEndpoint,
activeSessionTabId,
cachedTabStrip,
initialSessionAutoCreateRef,
terminalFrameHeightRef,
handleCreateTerminal,
@@ -58,6 +62,23 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
// Why: a reconnect to a workspace this phone has already drawn should re-draw it, not blank
// the screen while the RPCs land. See mobile-session-reconnect-view-state.
const reconnectViewState = selectMobileSessionReconnectViewState({
connState,
verdictKind: connectionVerdict.kind,
terminalsLoaded,
liveTabCount: visibleTabs.length,
activeHandle,
cachedPreview: cachedTabStrip
})
const tabStripRows = getMobileSessionTabStripRows({
liveTabs: visibleTabs,
activeSessionTabId,
preview:
reconnectViewState.kind === 'reconnecting-with-cache' ? reconnectViewState.preview : null
})
const terminalSummary =
connState === 'connected'
? showLoadingState
@@ -88,6 +109,8 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
return {
showLoadingState,
showEmptyState,
reconnectViewState,
tabStripRows,
connectionVerdict,
showConnectionRetry,
terminalSummary,
@@ -97,5 +120,5 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
}
}
export type MobileSessionPresentationModel = MobileSessionBulkCloseModel &
export type MobileSessionPresentationModel = MobileSessionTabStripCacheModel &
ReturnType<typeof useMobileSessionPresentation>
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import {
getSessionTabStripCacheKey,
loadCachedSessionTabStrip,
readCachedSessionTabStrip,
saveCachedSessionTabStrip
} from '../cache/session-tab-strip-cache'
import {
toMobileSessionTabStripPreview,
type MobileSessionTabStripPreview
} from './mobile-session-tab-strip-entries'
import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close'
/**
* Keeps the last drawn tab strip for this workspace on the device, so a reconnect has something
* to render before the first snapshot lands. See mobile-session-reconnect-view-state.
*/
export function useMobileSessionTabStripCache(scope: MobileSessionBulkCloseModel) {
const { hostId, worktreeId, connState, terminalsLoaded } = scope
const { visibleTabs, activeSessionTabId, activeHandle } = scope
const cacheKey = getSessionTabStripCacheKey(hostId, worktreeId)
// Why: state settles a commit behind the key it was read for, so carry the key with it —
// otherwise the first render after a workspace switch draws the previous workspace's strip.
const [loaded, setLoaded] = useState<{
key: string | null
preview: MobileSessionTabStripPreview | null
}>(() => ({ key: cacheKey, preview: readCachedSessionTabStrip(cacheKey) }))
useEffect(() => {
// Synchronous first, so an in-session revisit never blinks through the uncached branch.
setLoaded({ key: cacheKey, preview: readCachedSessionTabStrip(cacheKey) })
let disposed = false
void loadCachedSessionTabStrip(cacheKey).then((preview) => {
if (!disposed) {
setLoaded({ key: cacheKey, preview })
}
})
return () => {
disposed = true
}
}, [cacheKey])
const cachedTabStrip = loaded.key === cacheKey ? loaded.preview : null
// Only a host-confirmed strip is worth persisting, and an emptied workspace has to be written
// too — skipping it would leave yesterday's tabs to be drawn over a session that no longer has
// them. The one reading we do not trust is a live terminal with no tab record behind it, which
// is the same case the empty state refuses to claim (use-mobile-session-presentation).
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup
useEffect(() => {
if (connState !== 'connected' || !terminalsLoaded) {
return
}
if (visibleTabs.length === 0 && activeHandle !== null) {
return
}
saveCachedSessionTabStrip(
cacheKey,
toMobileSessionTabStripPreview(visibleTabs, activeSessionTabId)
)
}, [activeHandle, activeSessionTabId, cacheKey, connState, terminalsLoaded, visibleTabs])
return { cachedTabStrip }
}
export type MobileSessionTabStripCacheModel = MobileSessionBulkCloseModel &
ReturnType<typeof useMobileSessionTabStripCache>
@@ -16,6 +16,29 @@ describe('connection log buffer', () => {
expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2'])
})
it('retains phase timings through redaction and evicts them with the cap', () => {
const store = createConnectionLogStore(2)
store.append('host-a', {
...entry(1),
timing: { kind: 'connection-state', name: 'reconnecting', ms: 800, complete: true }
})
store.append('host-a', {
...entry(2),
detail: '4280ms in connecting; resumeToken=secret-resume-token',
timing: { kind: 'connection-state', name: 'connecting', ms: 4_280, complete: true }
})
store.append('host-a', {
...entry(3),
timing: { kind: 'relay-dial-stage', name: 'awaiting-hello', ms: 9_100, complete: false }
})
expect(store.get('host-a').map((e) => e.timing)).toEqual([
{ kind: 'connection-state', name: 'connecting', ms: 4_280, complete: true },
{ kind: 'relay-dial-stage', name: 'awaiting-hello', ms: 9_100, complete: false }
])
expect(store.get('host-a')[0]!.detail).toBe('4280ms in connecting; resumeToken=[redacted]')
})
it('drops the oldest entries past the cap', () => {
const store = createConnectionLogStore(3)
for (let i = 1; i <= 5; i++) {
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { DirectConnectionLog } from './direct-connection-log'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import type { ConnectionLogEntry, ConnectionState } from './types'
function openStateWithLog(sink?: (entry: ConnectionLogEntry) => void) {
const entries: ConnectionLogEntry[] = []
const log = new DirectConnectionLog(
'ws://192.168.1.50:6768',
sink ?? ((entry) => entries.push(entry))
)
let now = 0
const state = new RpcClientConnectionState({
endpoint: 'ws://192.168.1.50:6768',
getReconnectAttempt: () => 0,
isClosed: () => false,
onStateDwell: log.stateDwell,
now: () => now
})
const publishAfter = (elapsedMs: number, next: ConnectionState): void => {
now += elapsedMs
state.publish(next)
}
return { entries, state, publishAfter }
}
describe('connection state dwell logging', () => {
it('records the time spent in each state as a structured log entry', () => {
const { entries, publishAfter } = openStateWithLog()
publishAfter(300, 'connecting')
publishAfter(4_200, 'handshaking')
publishAfter(250, 'connected')
expect(entries.map((entry) => entry.timing)).toEqual([
{ kind: 'connection-state', name: 'disconnected', ms: 300, complete: true },
{ kind: 'connection-state', name: 'connecting', ms: 4_200, complete: true },
{ kind: 'connection-state', name: 'handshaking', ms: 250, complete: true }
])
expect(entries[1]!.message).toBe('Connection state connecting → handshaking')
expect(entries[1]!.detail).toBe('4200ms in connecting')
})
it('skips transitions too short to explain a slow connect', () => {
const { entries, publishAfter } = openStateWithLog()
publishAfter(99, 'connecting')
publishAfter(100, 'handshaking')
expect(entries.map((entry) => entry.timing?.name)).toEqual(['connecting'])
})
it('does not log a dwell when the state does not change', () => {
const { entries, publishAfter } = openStateWithLog()
publishAfter(500, 'connecting')
publishAfter(500, 'connecting')
expect(entries).toHaveLength(1)
})
it('still publishes the state when the log sink throws', () => {
const seen: ConnectionState[] = []
const { state, publishAfter } = openStateWithLog(() => {
throw new Error('sink exploded')
})
state.addListener((next) => seen.push(next))
const connected = state.waitForConnected()
publishAfter(500, 'connecting')
publishAfter(500, 'connected')
expect(seen).toEqual(['connecting', 'connected'])
expect(state.get()).toBe('connected')
return expect(connected).resolves.toBeUndefined()
})
})
+27 -1
View File
@@ -4,9 +4,15 @@ import type {
ConnectionLogEntry,
ConnectionLogLevel,
ConnectionLogSink,
ConnectionState,
MobileConnectionDiagnosticPath
} from './types'
// Why: every reconnect cycle walks four states, and the per-host buffer is capped.
// Logging sub-100ms transitions would halve the history a report can show while
// telling support nothing — those states are never where a slow connect spent time.
const MIN_LOGGED_DWELL_MS = 100
export class DirectConnectionLog {
private sequence = 0
private readonly path: MobileConnectionDiagnosticPath
@@ -22,7 +28,7 @@ export class DirectConnectionLog {
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
evidence?: Pick<ConnectionLogEntry, 'code' | 'path' | 'timing'>
): void => {
this.sink?.({
id: `log-${++this.sequence}-${Date.now()}`,
@@ -44,6 +50,26 @@ export class DirectConnectionLog {
)
}
// Why: how long the client sat in each ConnectionState used to go only to
// console, so a shared diagnostics report could not show where a slow connect
// spent its seconds.
stateDwell = (previous: ConnectionState, next: ConnectionState, dweltMs: number): void => {
if (dweltMs < MIN_LOGGED_DWELL_MS) {
return
}
this.emit('info', `Connection state ${previous} → ${next}`, `${dweltMs}ms in ${previous}`, {
timing: { kind: 'connection-state', name: previous, ms: dweltMs, complete: true }
})
}
retryScheduled = (message: string, detail?: string): void => {
this.emit('info', message, detail, { code: 'retry-scheduled' })
}
authenticationRejected = (message: string, detail?: string): void => {
this.emit('warn', message, detail, { code: 'authentication-rejected' })
}
connected = (): void => {
this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' })
}
+4 -5
View File
@@ -48,14 +48,14 @@ export class DirectRpcClient implements RpcClient {
this.reconnect = new RpcClientReconnectSchedule({
openConnection: () => this.openConnection(),
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
emitLog: (message, detail) =>
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
emitLog: this.connectionLog.retryScheduled
})
this.connectionState = new RpcClientConnectionState({
endpoint,
initialListener: options.onStateChange,
getReconnectAttempt: () => this.reconnect.getAttempt(),
isClosed: () => this.intentionallyClosed
isClosed: () => this.intentionallyClosed,
onStateDwell: this.connectionLog.stateDwell
})
this.streams = new RpcClientStreamRegistry({
nextId: () => this.nextId(),
@@ -102,8 +102,7 @@ export class DirectRpcClient implements RpcClient {
this.authenticationRetry = new RpcClientAuthenticationRetry({
endpoint,
stopLiveness: () => this.stopLiveness(),
emitWarning: (message, detail) =>
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
emitWarning: this.connectionLog.authenticationRejected,
retry: (reason) => this.retryAuthentication(reason),
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
})
@@ -17,6 +17,12 @@ vi.mock('./host-store', () => ({
}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
import {
getSessionTabStripCacheKey,
readCachedSessionTabStrip,
resetSessionTabStripCacheForTests,
saveCachedSessionTabStrip
} from '../cache/session-tab-strip-cache'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
@@ -26,7 +32,9 @@ describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
asyncStorage.removeItem.mockClear()
asyncStorage.setItem.mockReset().mockResolvedValue(undefined)
resetHostNotificationSessionsForTests()
resetSessionTabStripCacheForTests()
})
it('closes the client only after metadata removal commits', async () => {
@@ -88,4 +96,40 @@ describe('host removal lifecycle', () => {
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
})
it('drops the removed host cached tab strip and keeps every other host', async () => {
// Why: the strip is plaintext and nothing else in the app ever expires an entry, so a
// forgotten host would keep its tab titles on disk and get them rewritten by the next
// save for any surviving host.
removeHostMock.mockResolvedValue(undefined)
const removed = getSessionTabStripCacheKey('host-1', 'wt-1')
const kept = getSessionTabStripCacheKey('host-2', 'wt-1')
const strip = {
tabs: [{ id: 'tab-1', type: 'terminal' as const, title: 'Terminal', agentId: null }],
activeTabId: 'tab-1'
}
saveCachedSessionTabStrip(removed, strip)
saveCachedSessionTabStrip(kept, strip)
await removeHostAndCloseClient('host-1', vi.fn())
// Fire-and-forget, like clearWatermark above; let its microtasks land.
await vi.waitFor(() => expect(readCachedSessionTabStrip(removed)).toBeNull())
expect(readCachedSessionTabStrip(kept)?.tabs).toHaveLength(1)
})
it('finishes the removal even when the cached tab strip write fails', async () => {
// The metadata removal has already committed and the client is closed by this
// point, so a cache write that fails must be reported, not thrown: surfacing it
// as a failed removal would leave the user staring at a host that is really gone.
removeHostMock.mockResolvedValue(undefined)
asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const closeHostClient = vi.fn()
await expect(removeHostAndCloseClient('host-1', closeHostClient)).resolves.toBeUndefined()
expect(closeHostClient).toHaveBeenCalledWith('host-1')
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
@@ -1,3 +1,4 @@
import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache'
import {
clearWatermark,
forgetHostNotificationSession
@@ -17,4 +18,12 @@ export async function removeHostAndCloseClient(
// re-pair of the same host would inherit a watermark for a counter it never saw.
forgetHostNotificationSession(hostId)
void clearWatermark(hostId)
// Why: the cached tab strip is plaintext and host-scoped, so forgetting the host has to drop
// it here too — nothing else in the app ever expires an entry. Awaited so a storage failure
// is observed rather than swallowed, but never fatal: the metadata removal has already
// committed and the client is closed, so failing here would report a finished removal as
// failed. The cache refuses further saves for this host either way.
await deleteCachedSessionTabStripForHost(hostId).catch((error: unknown) => {
console.warn('[host-removal] cached tab strip delete failed', error)
})
}
@@ -28,7 +28,10 @@ function fixture() {
}),
host: () => host,
canSchedule: () => true,
canDial: () => true,
canAttempt: () => true,
// These cases model a live relay session, so hysteresis still arbitrates.
adoptsOutright: () => false,
beginOperation: () => {},
migrate: async () => {},
onDirectMigrated: async () => {},
@@ -6,8 +6,11 @@ import type { MobileConnectionPath } from './stable-logical-rpc-client'
const DIRECT_PROBE_INTERVAL_MS = 15_000
// While the runtime channel rides the relay, periodically probe the direct
// endpoint and migrate back once hysteresis proves it stable.
// Re-acquires the direct endpoint while the runtime channel rides the relay.
// Two adoption policies, because what is at stake differs:
// - against a live relay, hysteresis must prove direct stable before the swap;
// - during a reconnect nothing is live, so this dial races the relay dial from
// t=0 and the first authenticated socket is adopted outright.
export class DirectReturnProbe {
private timer: ReturnType<typeof setTimeout> | null = null
@@ -27,7 +30,13 @@ export class DirectReturnProbe {
hysteresis: MobileEndpointHysteresis
host: () => HostProfile
canSchedule: () => boolean
// A dial is a pure observation on its own socket, so it only needs a live
// supervisor; the cutover is the part that needs the operation mutex.
canDial: () => boolean
canAttempt: () => boolean
// True while no session is live: the reconnect is a race, so an
// authenticated direct socket wins without consulting hysteresis.
adoptsOutright: () => boolean
// Takes the supervisor's operation mutex, now held for the cutover only.
beginOperation: () => void
migrate: (
@@ -61,6 +70,16 @@ export class DirectReturnProbe {
}, delayMs)
}
// Why: a reconnect races both paths from t=0, and schedule(0) yields to a
// pending 15s tick — that would hand the relay dial a head start by another name.
probeNow(): void {
if (this.stopped || this.activeProbe) {
return
}
this.clear()
this.schedule(0)
}
clear(): void {
this.deferredDelayMs = null
if (this.timer) {
@@ -79,7 +98,11 @@ export class DirectReturnProbe {
if (this.stopped) {
return
}
if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) {
// Why: the failure cooldown exists to stop a healthy relay flapping onto a
// marginal LAN. With nothing connected there is no session to protect, and
// honouring it would leave the phone waiting on relay alone.
const racing = this.hooks.adoptsOutright()
if (!this.hooks.canDial() || (!racing && !this.hooks.hysteresis.canProbe(this.deps.now()))) {
this.schedule()
return
}
@@ -90,7 +113,8 @@ export class DirectReturnProbe {
try {
// Why: the dial is a pure observation on its own socket — holding the
// supervisor's mutex across its 12s budget stalled every relay recovery
// that landed during a foreground return. Only the cutover needs the mutex.
// that landed during a foreground return, and makes the reconnect race
// unwinnable while a relay dial holds it.
successful = await openAuthenticatedDirectEndpoint(
this.hooks.host(),
this.deps.openDirect,
@@ -106,23 +130,40 @@ export class DirectReturnProbe {
}
// Both early returns leave the candidate to the finally, which owns it until
// migration takes over — closing here too would double-close it.
if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) {
const outright = this.hooks.adoptsOutright()
// Why: a socket that entered the race and lost books nothing and leaves the
// promotion streak untouched — the winner is this reconnect's whole verdict.
if (!outright && (racing || !this.hooks.hysteresis.recordDirectSuccess(this.deps.now()))) {
return
}
if (!this.hooks.canAttempt()) {
const mutexFree = this.hooks.canAttempt()
if (!mutexFree && !outright) {
// A relay dial owns the mutex; the streak survives, so the next probe
// promotes direct instead of this one.
return
}
this.hooks.beginOperation()
owned = true
if (mutexFree) {
this.hooks.beginOperation()
owned = true
}
// Why: when a relay dial holds the mutex the race still cuts over — that
// dial withdraws itself in migrateTo and books no failure against relay.
const candidate = successful
// Migration owns the candidate, including closing it if cutover is canceled.
successful = null
// Why: the relay dial can authenticate between this socket's authentication
// and the swap. migrateTo re-checks after auth, so the loser withdraws.
const abortCutover = outright
? (): boolean => this.stopped || !this.hooks.adoptsOutright()
: (): boolean => this.stopped
try {
await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped)
await this.hooks.migrate(candidate.client, candidate.path, abortCutover)
} catch (error) {
if (this.stopped) {
// Why: a withdrawn cutover is the ordinary end of a lost race, and
// migrateTo has already closed the candidate. Only the timer calls this
// method, and it discards the promise, so rethrowing here would surface
// a routine loss as an unhandled rejection.
if (this.stopped || abortCutover()) {
return
}
throw error
@@ -0,0 +1,362 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
unreachableDirect
} from './mobile-endpoint-supervisor-test-fakes'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
// Holds the relay cutover open so the direct path can authenticate mid-dial. The
// fake's migrateTo otherwise settles inside the dial, which no real cell does.
function holdRelayCutover(logical: FakeLogicalClient): () => void {
const settle = logical.migrateTo.getMockImplementation()!
let release!: () => void
const held = new Promise<void>((resolve) => {
release = resolve
})
logical.migrateTo.mockImplementationOnce(async (session, path, timeoutMs, shouldAbort) => {
await held
// Why: replays the real post-authentication checks, so a superseded dial
// still withdraws instead of stealing the client from the winner.
return await settle(session, path, timeoutMs, shouldAbort)
})
return release
}
// One full lost race: the relay dial starts, direct returns mid-cutover and wins.
async function loseOneRace(
logical: FakeLogicalClient,
openRelay: ReturnType<typeof vi.fn>
): Promise<void> {
const before = openRelay.mock.calls.length
const release = holdRelayCutover(logical)
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay.mock.calls.length).toBe(before + 1)
logical.publishState('connected')
release()
await vi.advanceTimersByTimeAsync(0)
}
function relaySessionsFrom(openRelay: ReturnType<typeof vi.fn>): FakeRelaySession[] {
return openRelay.mock.results.map((result) => result.value as FakeRelaySession)
}
describe('mobile endpoint reconnect race', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('dials relay at t=0 while the direct dial is still connecting', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies({ openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
// No timer advance at all: an unfinished direct dial buys no head start.
await supervisor.start()
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
supervisor.stop()
})
it('adopts the direct dial and withdraws the slower relay dial without booking it', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
// The direct dial authenticates while the cell is still cutting over.
logical.publishState('connected')
release()
await starting
expect(logical.getActivePath()).toBe('lan')
expect(relaySessionsFrom(openRelay)[0]!.close).toHaveBeenCalled()
// A withdrawn dial is not a failure: no cooldown is armed, so no redial lands.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.setRecoveryPath).toHaveBeenLastCalledWith(null)
supervisor.stop()
})
it('adopts a direct socket that wins a reconnect the relay path started', async () => {
const recordMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration')
const logical = new FakeLogicalClient('connected', 'relay')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
const release = holdRelayCutover(logical)
logical.publishState('disconnected')
// The direct dial runs while the relay dial is still in flight and wins it.
await vi.advanceTimersByTimeAsync(0)
expect(deps.openDirect).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('lan')
release()
await vi.advanceTimersByTimeAsync(0)
expect(relaySessionsFrom(openRelay)[0]!.close).toHaveBeenCalled()
// Hysteresis stamps the dwell, and the losing relay dial books no backoff.
expect(recordMigration).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('books one backoff, not two, when both paths lose the reconnect', async () => {
const recordDirectFailure = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordDirectFailure')
const logical = new FakeLogicalClient('connected', 'relay')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
expect(deps.openDirect).toHaveBeenCalledOnce()
expect(recordDirectFailure).toHaveBeenCalledOnce()
// One failure, so one 250ms step. A double-booked loss would redial at 500ms.
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(2)
supervisor.stop()
})
it('leaves the promotion streak alone when the direct socket loses the race', async () => {
const recordDirectSuccess = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordDirectSuccess')
const logical = new FakeLogicalClient('connected', 'relay')
const direct = new FakeSession('connecting')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay, openDirect: vi.fn(() => direct) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
const release = holdRelayCutover(logical)
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openDirect).toHaveBeenCalledOnce()
// Relay authenticates first, then the direct socket finally answers.
release()
await vi.advanceTimersByTimeAsync(0)
expect(logical.getActivePath()).toBe('relay')
direct.publishState('connected')
await vi.advanceTimersByTimeAsync(0)
expect(direct.close).toHaveBeenCalled()
expect(recordDirectSuccess).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('ignores a loser that closes after the winner has been adopted', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
expect(logical.getActivePath()).toBe('lan')
// The withdrawn cell socket reports its close afterwards.
relaySessionsFrom(openRelay)[0]!.publishState('disconnected')
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.getState()).toBe('connected')
expect(logical.getActivePath()).toBe('lan')
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('withdraws the relay socket before it authenticates once direct wins', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const relaySession = new FakeRelaySession('connecting')
const openRelay = vi.fn(() => relaySession)
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
expect(relaySession.close).not.toHaveBeenCalled()
// The direct dial authenticates while the cell socket is still pre-handshake.
// migrateTo would not withdraw until after E2EE auth, so the cell would have
// reserved a splice and the desktop would have finished a handshake for it.
logical.publishState('connected')
expect(relaySession.close).toHaveBeenCalled()
expect(relaySession.getState()).not.toBe('connected')
release()
await starting
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.getActivePath()).toBe('lan')
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('damps the race after a loss so a flapping LAN opens one cell socket', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connecting'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
// The first blip races, and the returning direct dial wins it.
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
expect(openRelay).toHaveBeenCalledOnce()
// Two more blips inside the damper window open no further cell socket.
for (const _blip of [1, 2]) {
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(100)
logical.publishState('connected')
await vi.advanceTimersByTimeAsync(400)
}
expect(openRelay).toHaveBeenCalledOnce()
// The window lapses against a live direct path, so it still opens nothing.
await vi.advanceTimersByTimeAsync(10_000)
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('races at once when the LAN dies inside a damper window grown to the cap', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connecting'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
// Four more losses, each once its window has run: 2s, 4s, 8s, 16s, then the
// fifth earns the 30s cap.
for (const window of [2_000, 4_000, 8_000, 16_000]) {
await vi.advanceTimersByTimeAsync(window)
await loseOneRace(logical, openRelay)
}
expect(openRelay).toHaveBeenCalledTimes(5)
// This time direct does not come back. Waiting out the window a blip earned
// would strand the phone offline for 30s with nothing else scheduled.
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(5)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay.mock.calls.length).toBeGreaterThan(5)
supervisor.stop()
})
it('lets a foreground resume race immediately inside a damper window', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connecting'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(100)
expect(openRelay).toHaveBeenCalledOnce()
// A resume is the user waiting on the screen; it never serves out the window.
supervisor.setForeground(false)
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(openRelay.mock.calls.length).toBeGreaterThan(1)
supervisor.stop()
})
it('starts no dial in the background and races both paths on resume', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies({ openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
supervisor.setForeground(false)
await supervisor.start()
await vi.advanceTimersByTimeAsync(60_000)
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('runs the resume probe against a relay that survived the background grace', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(1_000)
expect(deps.openDirect).not.toHaveBeenCalled()
// A live relay is not a reconnect: the resume probe dials direct, but the
// promotion still has to earn its hysteresis streak.
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openDirect).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.stop()
})
})
@@ -6,7 +6,8 @@ import {
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host
host,
unreachableDirect
} from './mobile-endpoint-supervisor-test-fakes'
// A cell that authenticates and then answers the confirm for a different relay host
@@ -87,7 +88,12 @@ describe('mobile endpoint supervisor direct probe', () => {
const recordMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration')
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => confirmRejectingRelaySession(logical))
const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) })
// No LAN to race: this is about the relay cadence after a confirm failure.
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
@@ -204,6 +204,15 @@ export const bundle: MobileRelayCredentialBundle = {
}
}
// Why: LAN unreachable. A throwing open beats a never-answering socket — the
// direct dial resolves synchronously, so a relay-only test leaves no probe timer
// behind and the reconnect race has exactly one runner.
export function unreachableDirect(): MobileEndpointSupervisorDependencies['openDirect'] {
return vi.fn(() => {
throw new Error('direct endpoint unreachable')
})
}
export function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
@@ -10,7 +10,8 @@ import {
FakeSession,
host,
mockCredentialRotation,
relay
relay,
unreachableDirect
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
@@ -48,19 +49,17 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('fails over when the direct retry loop publishes reconnecting', async () => {
it('fails over while the direct retry loop is still dialing', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const deps = dependencies({ openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
// An unfinished direct dial no longer holds relay back, so the failover has
// already happened by the time the direct client gives up.
logical.publishState('handshaking')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(deps.openRelay).toHaveBeenCalledOnce()
logical.publishState('reconnecting')
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
@@ -148,11 +147,12 @@ describe('mobile endpoint supervisor', () => {
expect(logical.getPendingPath()).toBeNull()
})
it('does not spend a queued relay retry while direct authentication is progressing', async () => {
it('keeps retrying relay on its own cadence while a direct handshake drags on', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -160,12 +160,13 @@ describe('mobile endpoint supervisor', () => {
await supervisor.start()
expect(openRelay).toHaveBeenCalledOnce()
// A direct dial that reaches 'handshaking' and stays there used to park relay
// recovery until it gave up; the retry now runs on the failure cadence alone.
logical.publishState('handshaking')
await vi.advanceTimersByTimeAsync(250)
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
logical.publishState('disconnected')
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(2)
supervisor.stop()
})
@@ -244,6 +245,7 @@ describe('mobile endpoint supervisor', () => {
const deps = dependencies({
openRelay,
onLog,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -288,6 +290,7 @@ describe('mobile endpoint supervisor', () => {
const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -470,6 +473,7 @@ describe('mobile endpoint supervisor', () => {
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
writeBundle: vi.fn(() => writePending),
randomBytes: () => new Uint8Array([128, 0])
})
@@ -808,6 +812,7 @@ describe('mobile endpoint supervisor', () => {
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -844,43 +849,6 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('races a relay dial when the direct dial stalls unauthenticated', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(2_499)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connecting')
// The direct dial never authenticates; the relay wins the race through migrateTo.
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
supervisor.stop()
})
it('cancels the grace race when the direct dial authenticates first', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('connected')
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('lan')
supervisor.stop()
})
it('never races a relay dial against a desktop with no relay endpoint', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
@@ -893,39 +861,4 @@ describe('mobile endpoint supervisor', () => {
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
})
it('drops the pending grace race when the phone backgrounds', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
})
it('books the shared cooldown when the grace race loses its dial', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(2_500)
expect(openRelay).toHaveBeenCalledOnce()
// The armed retry runs unforced, so it yields to the still-progressing direct
// dial: the race gets one attempt, never a socket-per-cooldown loop.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
// Direct finally gives up: ordinary recovery still owns the failure.
logical.publishState('reconnecting')
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
supervisor.stop()
})
})
@@ -10,14 +10,11 @@ import {
} from './mobile-endpoint-supervisor-support'
import { selectDialableRelayCredentials } from './mobile-relay-credential-selection'
import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log'
import {
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import { MobileRelayCredentialRefresh } from './mobile-relay-credential-refresh'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
import { RelayRecoveryIntentQueue } from './relay-recovery-intent-queue'
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
import { RelayLostRaceDamper } from './mobile-relay-lost-race-damper'
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
@@ -41,7 +38,7 @@ export class MobileEndpointSupervisor {
private operationInFlight = false
private readonly pending = new RelayRecoveryIntentQueue()
private readonly nudgeRouter: MobileEndpointNudgeRouter
private credentialRotationInFlight = false
private readonly credentialRefresh: MobileRelayCredentialRefresh
private relayRotationPending = false
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
@@ -49,7 +46,7 @@ export class MobileEndpointSupervisor {
private readonly leaseRotation: RelayLeaseRotationTimer
private readonly logRelay: RelayRecoveryLog
private readonly directProbe: DirectReturnProbe
private readonly directGrace: MobileRelayDirectGraceTimer
private readonly lostRace: RelayLostRaceDamper
private readonly backgroundGrace: MobileRelayBackgroundGrace
private readonly sessionEstablisher: MobileRelaySessionEstablisher
@@ -65,6 +62,27 @@ export class MobileEndpointSupervisor {
minimumDwellMs: MINIMUM_DWELL_MS
})
this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog)
this.credentialRefresh = new MobileRelayCredentialRefresh({
logical,
now: dependencies.now,
randomBytes: dependencies.randomBytes,
writeBundle: dependencies.writeBundle,
bundle: () => this.bundle,
adoptBundle: (bundle) => (this.bundle = bundle),
persistResolvedRelay: async (resolved) => {
this.host = await persistRelayHost(this.host, resolved, dependencies.saveHost)
},
isStopped: () => this.stopped,
completeRefresh: () => this.relayReconnect.completeCredentialRefresh(),
// Why relayDialAllowed and not the reconnect controller's needsRecovery: a
// refresh that lands while direct is still dialing must start the relay race,
// not wait on the direct retry loop as the pre-race rotation path did.
onRefreshed: () => {
if (this.isActive() && this.relayDialAllowed(false)) {
void this.recoverRelay()
}
}
})
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
this.relayReconnect.reportRecoveryTo(logical)
this.nudgeRouter = new MobileEndpointNudgeRouter({
@@ -74,18 +92,17 @@ export class MobileEndpointSupervisor {
isForeground: () => this.backgroundGrace.isForeground(),
setForeground: (foreground) => this.setForeground(foreground),
replaceRelay: () => void this.recoverRelay(true, true),
scheduleDirectProbe: () => this.directProbe.schedule(0)
scheduleDirectProbe: () => this.directProbe.probeNow()
})
this.lostRace = new RelayLostRaceDamper(dependencies, () => {
// Why: the window closing is the moment to re-ask. If direct came back the
// guards below no-op; if it never did, relay recovery resumes on its own.
void this.recoverRelay()
})
this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => {
this.relayRotationPending = true
void this.recoverRelay(true)
})
// Why: the race owns recovery exactly like a network-change replacement — its
// failure must book the shared cooldown. recoverRelay's own guards already
// cover stopped/background/no-relay, so the timer needs no scope check.
this.directGrace = new MobileRelayDirectGraceTimer(dependencies, logical, () => {
void this.recoverRelay(true, true)
})
this.sessionEstablisher = new MobileRelaySessionEstablisher({
logical,
controller: this.relayReconnect,
@@ -103,6 +120,7 @@ export class MobileEndpointSupervisor {
adoptBundle: (bundle) => (this.bundle = bundle),
recordMigration: () => {
this.relayRotationPending = false
this.lostRace.reset()
this.hysteresis.recordMigration(dependencies.now())
logRelayConnected(this.logRelay)
},
@@ -119,13 +137,17 @@ export class MobileEndpointSupervisor {
hysteresis: this.hysteresis,
host: () => this.host,
canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay',
canDial: () => this.isActive(),
canAttempt: () => this.isActive() && !this.operationInFlight,
// Why: a reconnect has no session to protect, so the first authenticated
// socket wins it outright — hysteresis only arbitrates against a live relay.
adoptsOutright: () => this.isActive() && this.logical.getState() !== 'connected',
beginOperation: () => (this.operationInFlight = true),
migrate: (client, path, abort) => this.logical.migrateTo(client, path, undefined, abort),
onDirectMigrated: async () => {
this.leaseRotation.clear()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
await this.credentialRefresh.run(this.relayReconnect.resetForDirectConnection())
},
afterProbe: () => {
this.operationInFlight = false
@@ -140,8 +162,7 @@ export class MobileEndpointSupervisor {
logical,
this.relayReconnect,
this.leaseRotation,
this.directProbe,
this.directGrace
this.directProbe
)
}
@@ -157,12 +178,18 @@ export class MobileEndpointSupervisor {
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
this.directGrace.clear()
this.lostRace.noteDirectRestored()
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
void this.credentialRefresh.run(this.relayReconnect.resetForDirectConnection())
}
this.directProbe.schedule()
} else if (!this.backgroundGrace.isForeground()) {
return
}
// Why: the path that won the last race is gone, so the window it earned
// must not be served out — a blip that became an outage would otherwise
// strand the user for the whole window with nothing else scheduled.
this.lostRace.clampForLostDirect()
if (!this.backgroundGrace.isForeground()) {
this.backgroundGrace.handleStateFailure()
} else {
// Why: the direct client enters reconnecting after its first failed
@@ -172,17 +199,21 @@ export class MobileEndpointSupervisor {
logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
}
})
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
// Why: the first direct dial can fail while encrypted relay credentials
// are still loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
} else {
if (this.logical.getState() === 'connected') {
this.directProbe.schedule()
this.directGrace.arm()
return
}
// Why: nothing is live, so both paths dial from t=0. This also covers the
// first direct dial failing while encrypted relay credentials are still
// loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
}
setForeground(foreground: boolean): void {
if (foreground) {
// Why: a resume is the user waiting on the screen, never a blip.
this.lostRace.reset()
}
this.backgroundGrace.setForeground(foreground)
if (foreground && this.relayRotationPending) {
void this.recoverRelay(true)
@@ -194,6 +225,7 @@ export class MobileEndpointSupervisor {
stop(): void {
this.stopped = true
this.pending.clear()
this.lostRace.reset()
this.directProbe.stop()
this.unsubscribeState?.()
this.unsubscribeState = null
@@ -204,8 +236,15 @@ export class MobileEndpointSupervisor {
return !this.stopped && this.backgroundGrace.isForeground()
}
// forceReplacement: dial past the "direct still looks live" guard — a lease
// rotation, a network-change replacement, or the happy-eyeballs grace race.
// Why: the relay dial yields to a live session and to nothing else. An
// unfinished direct dial ('connecting'/'handshaking') used to block it behind a
// fixed head start, which bought an off-LAN phone nothing on every reconnect.
private relayDialAllowed(forceReplacement: boolean): boolean {
return forceReplacement || this.logical.getState() !== 'connected'
}
// forceReplacement: dial past the "a live session already holds the client"
// guard — a lease rotation or a network-change replacement.
// ownsRecovery: this dial is the connection's only hope, so a failure books the
// shared cooldown and any session left stale-'connected' by a half-open socket
// comes down; lease rotation clears it because armRetry owns its own retry.
@@ -213,6 +252,11 @@ export class MobileEndpointSupervisor {
if (!this.isActive() || !this.host.relay) {
return
}
if (this.logical.getState() !== 'connected') {
// Why: both paths race from t=0. This no-ops unless relay owns the logical
// client — when direct owns it, its own session is already redialing.
this.directProbe.probeNow()
}
if (this.operationInFlight) {
// Why: a direct cutover or a slow post-migration write can own the mutex when
// a handoff lands. Every request is queued — an owning replacement keeps its
@@ -225,9 +269,13 @@ export class MobileEndpointSupervisor {
forceReplacement = true
ownsRecovery = true
}
// Why: connecting/handshaking is live direct progress; an unforced relay dial
// would race it before the grace timer has given direct its head start.
if (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) {
if (!this.relayDialAllowed(forceReplacement)) {
return
}
if (!forceReplacement && this.lostRace.suppresses()) {
// Why: the previous race was lost to direct and booked nothing, so only
// this damper stands between a flapping LAN and a cell socket per blip.
this.logRelay('relay race damped after losing to direct')
return
}
// Why: revival and lease timers can overlap resume failures; one shared cooldown
@@ -264,9 +312,7 @@ export class MobileEndpointSupervisor {
}
return
}
const recoveryNeeded =
forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState())
if (!this.isActive() || !recoveryNeeded) {
if (!this.isActive() || !this.relayDialAllowed(forceReplacement)) {
return
}
this.logical.setRecoveryPath('relay', this.relayReconnect.getFailureCount())
@@ -281,6 +327,12 @@ export class MobileEndpointSupervisor {
this.logical.setRecoveryPath(null)
// Why: direct won the race or the supervisor went inactive — not a
// failure; booking backoff would delay the next genuine recovery.
// Why: only an unforced race can be blip-driven. A forced replacement
// that stands down is a lease rotation or a network change reconsidered,
// not a LAN that flapped, so it must not grow the streak.
if (!forceReplacement && this.isActive() && this.logical.getState() === 'connected') {
this.lostRace.record()
}
return
}
// Why: cleanup may happen while a relay dial is awaiting the network;
@@ -303,45 +355,4 @@ export class MobileEndpointSupervisor {
}
}
}
private async rotateCredentialIfNeeded(force = false): Promise<void> {
if (
this.stopped ||
this.credentialRotationInFlight ||
!this.bundle ||
this.logical.getActivePath() === 'relay' ||
(!force && !mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now()))
) {
return
}
this.credentialRotationInFlight = true
let credentialRefreshed = false
try {
const result = await rotateMobileRelayCredential({
client: this.logical,
bundle: this.bundle,
writeBundle: this.dependencies.writeBundle,
randomBytes: this.dependencies.randomBytes
})
this.bundle = result.bundle
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
credentialRefreshed = true
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
if (credentialRefreshed) {
this.relayReconnect.completeCredentialRefresh()
}
this.credentialRotationInFlight = false
if (
credentialRefreshed &&
this.isActive() &&
this.relayReconnect.needsRecovery(this.logical.getState())
) {
void this.recoverRelay()
}
}
}
}
@@ -51,8 +51,7 @@ export class MobileRelayBackgroundGraceTimer {
}
type Clearable = { clear(): void }
type DirectProbe = Clearable & { schedule(delayMs?: number): void }
type DirectGrace = Clearable & { arm(): void }
type DirectProbe = Clearable & { schedule(delayMs?: number): void; probeNow(): void }
export class MobileRelayBackgroundGrace {
private foregroundState = true
@@ -64,8 +63,7 @@ export class MobileRelayBackgroundGrace {
private readonly logical: StableLogicalRpcClient,
private readonly relayReconnect: RelayReconnectController,
private readonly leaseRotation: Clearable,
private readonly directProbe: DirectProbe,
private readonly directGrace: DirectGrace
private readonly directProbe: DirectProbe
) {
this.timer = new MobileRelayBackgroundGraceTimer(dependencies, () => this.suspendRelay())
}
@@ -80,8 +78,9 @@ export class MobileRelayBackgroundGrace {
if (foreground) {
this.foreground()
this.relayReconnect.handleForeground(this.logical, wasForeground)
this.directProbe.schedule(0)
this.directGrace.arm()
// Why: a resume dials direct alongside the relay recovery handleForeground
// just triggered; a pending probe tick must not delay this one.
this.directProbe.probeNow()
} else if (wasForeground) {
this.background()
}
@@ -92,7 +91,6 @@ export class MobileRelayBackgroundGrace {
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
@@ -108,7 +106,6 @@ export class MobileRelayBackgroundGrace {
const retainsRelay =
this.logical.getActivePath() === 'relay' && this.logical.getState() === 'connected'
this.directProbe.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
if (retainsRelay) {
this.timer.arm()
@@ -0,0 +1,71 @@
import {
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
// Mints a replacement relay credential over a live direct connection. That is the
// only moment it can happen: the replacement comes from an authenticated RPC, and
// a phone whose credential the relay has rejected cannot carry one over relay.
export class MobileRelayCredentialRefresh {
private inFlight = false
constructor(
private readonly args: {
logical: StableLogicalRpcClient
now: () => number
randomBytes: (length: number) => Uint8Array
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
bundle: () => MobileRelayCredentialBundle | null
adoptBundle: (bundle: MobileRelayCredentialBundle) => void
persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise<void>
isStopped: () => boolean
// Lifts the controller's fresh-credential gate once the replacement is durable.
completeRefresh: () => void
onRefreshed: () => void
}
) {}
// force: the caller already knows the current credential is rejected, so the
// age check would only delay a rotation the relay path is blocked on.
async run(force: boolean): Promise<void> {
const { args } = this
const bundle = args.bundle()
if (
args.isStopped() ||
this.inFlight ||
!bundle ||
args.logical.getActivePath() === 'relay' ||
(!force && !mobileRelayCredentialNeedsRotation(bundle, args.now()))
) {
return
}
this.inFlight = true
let refreshed = false
try {
const result = await rotateMobileRelayCredential({
client: args.logical,
bundle,
writeBundle: args.writeBundle,
randomBytes: args.randomBytes
})
args.adoptBundle(result.bundle)
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
refreshed = true
await args.persistResolvedRelay(result.relay)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
if (refreshed) {
args.completeRefresh()
}
this.inFlight = false
if (refreshed) {
args.onRefreshed()
}
}
}
}
@@ -1,48 +0,0 @@
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
// Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the
// whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery
// cannot even start meanwhile because connecting/handshaking count as live direct
// progress. Happy eyeballs: give direct this much of a head start, then race the
// relay dial — migrateTo hands the logical client to whichever authenticates first.
const DIRECT_DIAL_GRACE_MS = 2500
type DirectGraceTimerDependencies = {
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
// One-shot timer that releases the relay dial when the direct dial has not
// authenticated within the grace. The supervisor arms it at start and on
// foreground restore, and clears it on connect, background, and stop.
export class MobileRelayDirectGraceTimer {
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly dependencies: DirectGraceTimerDependencies,
private readonly logical: StableLogicalRpcClient,
private readonly dialRelay: () => void
) {}
// No-op unless the direct dial is still unauthenticated, so a healthy LAN and
// an already-failed direct path (recovery owns that) never open a relay socket.
arm(): void {
const state = this.logical.getState()
if (this.timer || (state !== 'connecting' && state !== 'handshaking')) {
return
}
this.timer = this.dependencies.setTimer(() => {
this.timer = null
if (this.logical.getState() !== 'connected') {
this.dialRelay()
}
}, DIRECT_DIAL_GRACE_MS)
}
clear(): void {
if (this.timer) {
this.dependencies.clearTimer(this.timer)
this.timer = null
}
}
}
@@ -195,6 +195,38 @@ describe('MobileRelayE2eeLink', () => {
}
})
it('writes no e2ee frame when withdrawn between relay-auth and the hello', () => {
const socket = new ThrowingSocket()
const sent: string[] = []
socket.send.mockImplementation((frame: string) => {
sent.push(frame)
})
const link = new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError: vi.fn(),
createSocket: () => socket as unknown as WebSocket
})
socket.onopen?.()
// The window a lost reconnect race is withdrawn in: the cell has the outer
// credential but has not answered, so no key exchange has started.
link.close()
expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0]!)).toMatchObject({ type: 'relay-auth' })
expect(socket.close).toHaveBeenCalledOnce()
})
it('cancels the missing-close timer when explicitly closed', async () => {
vi.useFakeTimers()
try {
@@ -0,0 +1,99 @@
// Paces the direct-vs-relay reconnect race after the relay dial loses it. A lost
// race books no failure — that is deliberate, since losing is the good outcome —
// so nothing else stops a flapping LAN from opening one cell socket per blip, and
// the relay's per-host rate limiter would eventually turn a benign race into a
// booked relay failure. This is not backoff: it never delays the failure path,
// and its window lapse re-enters recovery so a LAN that dies mid-window still
// reaches relay on its own.
const INITIAL_DAMP_MS = 2_000
const MAX_DAMP_MS = 30_000
// How long a lost direct path is given to prove it was only a blip. Long enough
// to absorb one that drops and comes straight back, short enough that a real
// outage never reads as the connection being stuck.
const LOST_DIRECT_FLOOR_MS = 250
type LostRaceDamperDependencies = {
now: () => number
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
export class RelayLostRaceDamper {
private windowMs = 0
private suppressUntil = 0
// The window held aside while a lost direct path proves whether it was a blip.
private pendingUntil = 0
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly dependencies: LostRaceDamperDependencies,
private readonly onWindowLapse: () => void
) {}
suppresses(): boolean {
return this.dependencies.now() < this.suppressUntil
}
// Each successive loss inside the window doubles it, so a LAN that flaps all
// afternoon settles at one race per 30s instead of one per blip.
record(): void {
this.windowMs = this.windowMs === 0 ? INITIAL_DAMP_MS : Math.min(this.windowMs * 2, MAX_DAMP_MS)
this.suppressUntil = this.dependencies.now() + this.windowMs
this.arm(this.windowMs)
}
// The direct path that won the last race is gone. Collapse the wait to the
// floor, so an outage is never held off for the window a blip earned, and keep
// the rest of that window aside rather than spending it: one blip must not buy
// a flapping LAN a free pass on every race that follows.
clampForLostDirect(): void {
const floorAt = this.dependencies.now() + LOST_DIRECT_FLOOR_MS
if (this.suppressUntil === 0 || this.pendingUntil !== 0 || this.suppressUntil <= floorAt) {
return
}
this.pendingUntil = this.suppressUntil
this.suppressUntil = floorAt
this.arm(LOST_DIRECT_FLOOR_MS)
}
// Direct came back inside the floor, so that was the blip this exists for and
// the rest of the window still has to run.
noteDirectRestored(): void {
if (this.pendingUntil === 0) {
return
}
this.suppressUntil = this.pendingUntil
this.pendingUntil = 0
this.arm(Math.max(0, this.suppressUntil - this.dependencies.now()))
}
// A relay dial that wins, or the user bringing the app back, ends the streak:
// neither is a blip, and a resume must never wait out a damper window. A relay
// failure deliberately does not — it is not evidence the LAN stopped flapping,
// and its own cooldown runs after this window rather than on top of it, since
// a damped attempt never reaches the dial that would book one.
reset(): void {
this.windowMs = 0
this.suppressUntil = 0
this.pendingUntil = 0
this.clearTimer()
}
private arm(delayMs: number): void {
this.clearTimer()
this.timer = this.dependencies.setTimer(() => {
this.timer = null
// Why: the floor lapsed with direct still gone, so it was an outage and the
// window held aside is void — a later return must not resurrect it.
this.pendingUntil = 0
this.onWindowLapse()
}, delayMs)
}
private clearTimer(): void {
if (this.timer) {
this.dependencies.clearTimer(this.timer)
this.timer = null
}
}
}
@@ -174,6 +174,22 @@ describe('mobile relay RPC session liveness', () => {
)
})
it('still terminates a dead relay when the log sink throws on the timeout line', async () => {
const onLog = vi.fn<ConnectionLogSink>(() => {
throw new Error('sink exploded')
})
const session = await authenticateSession(onLog)
session.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(4_000)
await vi.advanceTimersByTimeAsync(4_000)
// The line was attempted and threw; the session still came down.
expect(onLog).toHaveBeenCalledWith(expect.objectContaining({ code: 'liveness-timeout' }))
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
})
it('disconnects after two fair foreground misses', async () => {
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
@@ -9,25 +9,18 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
import { RelayDialStageLog } from './relay-dial-stage-log'
import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage'
import { RelayPendingRequests } from './relay-pending-requests'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { createRelaySessionLivenessWatchdog } from './relay-session-liveness-profile'
import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
import type { RpcClient } from './rpc-client'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
// Ordinary foreground checks: two 4s misses, at most one voluntary probe per 10s.
const RELAY_PROBE = { timeoutMs: 4_000, missedProbeLimit: 2, minIntervalMs: 10_000 }
// A socket that died while the process was suspended must be admitted before the
// user reads the screen as broken. Two 2s misses, not one: the first frame after a
// resume rides a cold radio, and a single slow answer is not proof of a dead link.
const RELAY_RESUME_PROBE = { timeoutMs: 2_000, missedProbeLimit: 2 }
// Bounds the confirm exactly as migrateTo's own wait used to, so the supervisor's
// mutex is never held for the full request timeout waiting on a silent cell.
const RELAY_CONFIRM_TIMEOUT_MS = 12_000
// Foreground-only sweep so a silently-dead relay surfaces without a user action.
const RELAY_IDLE_PROBE_MS = 25_000
let relayRpcSessionSequence = 0
export type MobileRelayRpcSession = RpcClient &
@@ -80,6 +73,7 @@ export function connectMobileRelayRpcSession(args: {
settleResumeConfirmed = resolve
})
const dialStage = new RelayDialStageTracker()
const dialStageLog = new RelayDialStageLog(dialStage, logSessionId, args.onLog)
const streams = new MobileRelayRpcStreams({
nextId: () => pending.nextId(),
sendFrame,
@@ -94,7 +88,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: args.desktopPublicKeyB64,
createSocket: args.createSocket,
onHostCloseReason: args.onHostCloseReason,
onOpen: () => dialStage.advance('awaiting-hello'),
onOpen: () => dialStageLog.enter('awaiting-hello'),
onHello: (hello) => {
if (
hello.credentialKind !== 'resume' ||
@@ -105,7 +99,7 @@ export function connectMobileRelayRpcSession(args: {
}
attachDeadlineAt = hello.leaseExpiresAt
resumeExpiresAt = hello.resumeExpiresAt
dialStage.advance('handshaking')
dialStageLog.enter('handshaking')
publishState('handshaking')
},
onAuthenticated: () => publishAuthenticated(),
@@ -159,30 +153,14 @@ export function connectMobileRelayRpcSession(args: {
whenResumeConfirmed: () => resumeConfirmed,
getFailure: () => failure
}
const livenessWatchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: RELAY_IDLE_PROBE_MS,
probeTimeoutMs: RELAY_PROBE.timeoutMs,
missedProbeLimit: RELAY_PROBE.missedProbeLimit,
voluntaryProbeMinIntervalMs: RELAY_PROBE.minIntervalMs,
urgentProbeTimeoutMs: RELAY_RESUME_PROBE.timeoutMs,
urgentMissedProbeLimit: RELAY_RESUME_PROBE.missedProbeLimit,
shouldIdleProbe: () => args.isForeground?.() ?? true,
const livenessWatchdog = createRelaySessionLivenessWatchdog({
isForeground: args.isForeground,
sendProbe: () =>
state === 'connected' &&
sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }),
onTimeout: (evidence) => {
args.onLog?.({
id: `relay-liveness-${logSessionId}-${++logSequence}`,
ts: Date.now(),
level: 'error',
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
})
},
terminate: () => fail(new Error('relay session liveness timeout'))
terminate: () => fail(new Error('relay session liveness timeout')),
onLog: args.onLog,
nextLogId: () => `relay-liveness-${logSessionId}-${++logSequence}`
})
return client
@@ -193,7 +171,7 @@ export function connectMobileRelayRpcSession(args: {
if (closed) {
return
}
dialStage.advance('confirming')
dialStageLog.enter('confirming')
void confirmResume().then(settleResumeConfirmed, settleResumeConfirmed)
// Why: an unanswered advisory says nothing, but a frame that never reached the
// wire proves the socket cannot carry traffic — that alone still fails.
@@ -224,6 +202,9 @@ export function connectMobileRelayRpcSession(args: {
}
resumeConfirmation = result.resumeConfirmation
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
// The dial's last stage ends when the desktop has confirmed the resume, not when
// 'connected' was published at authentication ahead of it.
dialStageLog.settle(true)
} catch (error) {
fail(asError(error))
}
@@ -325,6 +306,7 @@ export function connectMobileRelayRpcSession(args: {
}
closed = true
settleResumeConfirmed()
dialStageLog.settle(false, error.message)
livenessWatchdog.stop(livenessIdentity)
streams.clear()
link.close()
@@ -19,6 +19,25 @@ function directWon(logical: StableLogicalRpcClient): boolean {
return logical.getActivePath() !== 'relay' && logical.getState() === 'connected'
}
// Why: migrateTo consults its abort predicate only after E2EE authentication, so
// a dial that has already lost would still make the cell reserve a splice and the
// desktop finish a handshake. Closing the socket withdraws it at whatever stage it
// reached — before any e2ee frame when the hello has not landed yet. The caller
// still reports the dial as aborted, so nothing is booked against relay.
function withdrawWhenDirectWins(
logical: StableLogicalRpcClient,
session: { close(): void }
): () => void {
const withdraw = (): void => {
if (directWon(logical)) {
session.close()
}
}
const unsubscribe = logical.onStateChange(withdraw)
withdraw()
return unsubscribe
}
// Turns one relay credential into the active runtime session: resolve the cell
// assignment if the director rejects the cached one, open the cell socket,
// migrate the logical client onto it, then persist the resume confirmation and
@@ -113,6 +132,7 @@ export class MobileRelaySessionEstablisher {
},
args.isForeground
)
const stopWithdrawWatch = withdrawWhenDirectWins(args.logical, session)
try {
// Why: backgrounding or a direct winner withdraws this dial before cutover.
await args.logical.migrateTo(
@@ -126,6 +146,10 @@ export class MobileRelaySessionEstablisher {
return { ok: false, error: new RelayDialAbortedError() }
}
return { ok: false, error: session.getFailure() ?? toError(error) }
} finally {
// Why: past the cutover this session is the active path, and a later direct
// promotion must not read as a reason to close the client's own socket.
stopWithdrawWatch()
}
// Why: migrateTo now resolves at E2EE authentication, so the resume confirm can
// still fail this session after the cutover. Booking a dying session as an
+15
View File
@@ -0,0 +1,15 @@
// Why: connection phase durations must never go negative. Date.now() can jump
// backwards (NTP or a user clock change) mid-dial, which would turn a slow stage
// into a negative one in the diagnostics report. performance.now() is monotonic
// and Hermes exposes it; hosts without it fall back to wall clock.
const hasPerformanceNow =
typeof performance === 'object' && performance !== null && typeof performance.now === 'function'
export const monotonicNowMs: () => number = hasPerformanceNow
? () => performance.now()
: () => Date.now()
/** Whole milliseconds between two monotonic reads, clamped so a fallback wall-clock jump can't go negative. */
export function elapsedMs(startedAt: number, endedAt: number = monotonicNowMs()): number {
return Math.max(0, Math.round(endedAt - startedAt))
}
@@ -23,6 +23,89 @@ describe('persisted connection log store', () => {
vi.resetModules()
})
// 'negotiating' is not a dial stage and 'confirming' is a dial stage rather than a
// connection state; the report echoes the name, so neither may survive. A negative
// duration is corruption too: producers clamp at 0, and the report sums these, so a
// negative would subtract from a dial total.
it('rehydrates well-formed phase timings and drops corrupt names and durations', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify([
{
id: 'stage-ok',
ts: 900,
level: 'info',
message: 'Relay dial stage awaiting-hello finished',
timing: { kind: 'relay-dial-stage', name: 'awaiting-hello', ms: 6_400, complete: true }
},
{
id: 'stage-corrupt',
ts: 950,
level: 'info',
message: 'Relay dial stage handshaking finished',
timing: { kind: 'relay-dial-stage', name: 'handshaking', ms: 'soon' }
},
{
id: 'stage-unknown-name',
ts: 960,
level: 'info',
message: 'Relay dial stage negotiating finished',
timing: { kind: 'relay-dial-stage', name: 'negotiating', ms: 12, complete: true }
},
{
id: 'state-borrowed-stage-name',
ts: 970,
level: 'info',
message: 'Connection state confirming → connected',
timing: { kind: 'connection-state', name: 'confirming', ms: 12, complete: true }
},
{
id: 'state-unknown-kind',
ts: 980,
level: 'info',
message: 'Something else',
timing: { kind: 'wall-clock', name: 'connecting', ms: 12, complete: true }
},
{
id: 'stage-negative-ms',
ts: 985,
level: 'info',
message: 'Relay dial stage opening finished',
timing: { kind: 'relay-dial-stage', name: 'opening', ms: -1, complete: true }
},
{
id: 'state-negative-ms',
ts: 990,
level: 'info',
message: 'Connection state connecting → connected',
timing: { kind: 'connection-state', name: 'connecting', ms: -0.5, complete: true }
},
{
id: 'stage-zero-ms',
ts: 995,
level: 'info',
message: 'Relay dial stage confirming finished',
timing: { kind: 'relay-dial-stage', name: 'confirming', ms: 0, complete: true }
}
])
)
vi.resetModules()
const { connectionLogStore } = await import('./persisted-connection-log-store')
await connectionLogStore.hydrate('host-timings')
// 0 survives: a stage the dial passed through instantly is real, not corruption.
expect(connectionLogStore.get('host-timings').map((entry) => entry.id)).toEqual([
'stage-ok',
'stage-zero-ms'
])
expect(connectionLogStore.get('host-timings')[0]!.timing).toEqual({
kind: 'relay-dial-stage',
name: 'awaiting-hello',
ms: 6_400,
complete: true
})
})
it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
const stored: ConnectionLogEntry[] = [
{
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
import { RELAY_DIAL_STAGE_NAMES } from './relay-dial-stage'
import { CONNECTION_STATE_NAMES, type ConnectionLogEntry, type ConnectionLogTiming } from './types'
const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
@@ -72,6 +73,30 @@ function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
entry.level === 'warn' ||
entry.level === 'error') &&
typeof entry.message === 'string' &&
(entry.detail === undefined || typeof entry.detail === 'string')
(entry.detail === undefined || typeof entry.detail === 'string') &&
(entry.timing === undefined || isConnectionLogTiming(entry.timing))
)
}
// Why: the report echoes the phase name and formats the duration directly, so a
// corrupted stored timing must not reach it. The name is checked against the closed
// enum for its kind, not just "is a string", and the duration must be one a producer
// could have written — `elapsedMs` clamps at 0, so a negative is corruption.
function isConnectionLogTiming(value: unknown): value is ConnectionLogTiming {
if (!value || typeof value !== 'object') {
return false
}
const timing = value as Partial<ConnectionLogTiming>
if (timing.kind !== 'relay-dial-stage' && timing.kind !== 'connection-state') {
return false
}
const names = timing.kind === 'relay-dial-stage' ? RELAY_DIAL_STAGE_NAMES : CONNECTION_STATE_NAMES
return (
typeof timing.name === 'string' &&
Object.hasOwn(names, timing.name) &&
typeof timing.ms === 'number' &&
Number.isFinite(timing.ms) &&
timing.ms >= 0 &&
typeof timing.complete === 'boolean'
)
}
@@ -0,0 +1,55 @@
import type {
RelayDialStage,
RelayDialStageTracker,
RelayDialStageTiming
} from './relay-dial-stage'
import type { ConnectionLogSink } from './types'
// Why: support needs per-stage durations for a slow dial, and the name of the stage
// a failed dial died in, without a debug build. Timing only — advancing the tracker
// stays the session's call.
export class RelayDialStageLog {
private sequence = 0
constructor(
private readonly tracker: RelayDialStageTracker,
private readonly sessionId: string,
private readonly sink?: ConnectionLogSink
) {}
enter(stage: RelayDialStage): void {
this.record(this.tracker.advance(stage))
}
settle(complete: boolean, failureDetail?: string): void {
this.record(this.tracker.settle(complete), failureDetail)
}
private record(timing: RelayDialStageTiming | null, failureDetail?: string): void {
if (!timing) {
return
}
// Why: this runs inside the dial's success and failure paths. A sink that
// throws must not turn a good connect into a failed one.
try {
this.sink?.({
id: `relay-dial-stage-${this.sessionId}-${++this.sequence}`,
ts: Date.now(),
level: timing.complete ? 'info' : 'warn',
path: 'relay',
message: `Relay dial stage ${timing.stage} ${
timing.complete ? 'finished' : 'did not finish'
}`,
detail: `${timing.ms}ms${failureDetail ? ` — ${failureDetail}` : ''}`,
timing: {
kind: 'relay-dial-stage',
name: timing.stage,
ms: timing.ms,
complete: timing.complete
}
})
} catch {
// Diagnostics only; a broken sink is not worth failing a dial over.
}
}
}
@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayDialStageTracker } from './relay-dial-stage'
import type { ConnectionLogEntry } from './types'
const fakes = vi.hoisted(() => ({
linkOptions: null as null | {
onOpen(): void
onHello(value: unknown): void
onAuthenticated(): void
onText(value: string): void
onBinary(value: Uint8Array): void
onError(error: Error): void
},
sendText: vi.fn(() => true),
close: vi.fn()
}))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
fakes.linkOptions = options
}
sendText = fakes.sendText
close = fakes.close
}
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
function openSession(entries: ConnectionLogEntry[]) {
return connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 1000,
onLog: (entry) => entries.push(entry)
})
}
function stageTimings(entries: readonly ConnectionLogEntry[]) {
return entries.flatMap((entry) =>
entry.timing?.kind === 'relay-dial-stage' ? [entry.timing] : []
)
}
describe('RelayDialStageTracker timings', () => {
it('times every stage it passes through without going negative', () => {
// A clock that steps backwards proves the report can never show a negative stage.
const reads = [0, 120, 4_400, 4_300, 5_500]
let index = 0
const tracker = new RelayDialStageTracker(() => reads[index++]!)
expect(tracker.advance('awaiting-hello')).toEqual({
stage: 'opening',
ms: 120,
complete: true
})
expect(tracker.advance('handshaking')).toEqual({
stage: 'awaiting-hello',
ms: 4_280,
complete: true
})
expect(tracker.advance('confirming')).toEqual({
stage: 'handshaking',
ms: 0,
complete: true
})
expect(tracker.settle(true)).toEqual({ stage: 'confirming', ms: 1_200, complete: true })
expect(tracker.getDialStage()).toBe('confirming')
})
it('re-advancing to the current stage is not a transition', () => {
const tracker = new RelayDialStageTracker(() => 0)
expect(tracker.advance('opening')).toBeNull()
})
it('settles once, so a failure after connecting cannot re-time the last stage', () => {
let now = 0
const tracker = new RelayDialStageTracker(() => now)
tracker.advance('awaiting-hello')
now = 900
expect(tracker.settle(true)).toEqual({ stage: 'awaiting-hello', ms: 900, complete: true })
now = 90_000
expect(tracker.settle(false)).toBeNull()
})
})
function requestIdAt(call: number): string {
return (JSON.parse(fakes.sendText.mock.calls[call]![0] as string) as { id: string }).id
}
async function driveToConnected(session: {
getState(): string
whenResumeConfirmed(): Promise<void>
}): Promise<void> {
fakes.linkOptions!.onOpen()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 3,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
fakes.linkOptions!.onAuthenticated()
// 'connected' is published at authentication; the resume confirm and the capability
// advisory are both already on the wire, so answer them in the order they were sent.
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
expect(fakes.sendText).toHaveBeenCalledTimes(2)
fakes.linkOptions!.onText(
JSON.stringify({
id: requestIdAt(0),
ok: true,
result: {
v: 1,
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
currentVersion: 3,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: Date.now() + 300_000
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
fakes.linkOptions!.onText(
JSON.stringify({ id: requestIdAt(1), ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })
)
await session.whenResumeConfirmed()
}
describe('relay dial stage timings in the connection log', () => {
beforeEach(() => {
fakes.sendText.mockClear()
fakes.close.mockClear()
})
it('records the stages a failed dial reached plus the stage it died in', () => {
const entries: ConnectionLogEntry[] = []
openSession(entries)
fakes.linkOptions!.onOpen()
fakes.linkOptions!.onError(new Error('relay dial failed'))
const timings = stageTimings(entries)
expect(timings.map((timing) => timing.name)).toEqual(['opening', 'awaiting-hello'])
expect(timings.map((timing) => timing.complete)).toEqual([true, false])
for (const timing of timings) {
expect(timing.ms).toBeGreaterThanOrEqual(0)
}
expect(entries.at(-1)!.message).toContain('awaiting-hello did not finish')
expect(entries.at(-1)!.detail).toContain('relay dial failed')
expect(entries.at(-1)!.path).toBe('relay')
})
it('records every stage of a dial that reaches connected, all complete', async () => {
const entries: ConnectionLogEntry[] = []
const session = openSession(entries)
await driveToConnected(session)
const timings = stageTimings(entries)
expect(timings.map((timing) => timing.name)).toEqual([
'opening',
'awaiting-hello',
'handshaking',
'confirming'
])
expect(timings.every((timing) => timing.complete)).toBe(true)
expect(timings.every((timing) => timing.ms >= 0)).toBe(true)
// A later teardown must not append a second timing for 'confirming'.
session.close()
expect(stageTimings(entries)).toHaveLength(4)
})
it('reaches connected even when the log sink throws on every stage', async () => {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 1000,
onLog: () => {
throw new Error('sink exploded')
}
})
await driveToConnected(session)
expect(session.getState()).toBe('connected')
expect(session.getFailure()).toBeNull()
})
it('marks a dial that never opened its socket as stuck in opening', () => {
const entries: ConnectionLogEntry[] = []
openSession(entries)
fakes.linkOptions!.onError(new Error('websocket refused'))
expect(stageTimings(entries)).toEqual([
{ kind: 'relay-dial-stage', name: 'opening', ms: expect.any(Number), complete: false }
])
})
})
+47 -2
View File
@@ -1,3 +1,5 @@
import { elapsedMs, monotonicNowMs } from './monotonic-clock'
// Where a relay dial is waiting, so a bound can tell "the cell never answered the
// upgrade" from "the cell took the dial and is slow" — the two look identical from
// ConnectionState, which stays 'connecting' until relay-hello arrives.
@@ -12,6 +14,23 @@ export type RelayDialStage =
// E2EE authenticated; waiting on the desktop's resume confirmation.
| 'confirming'
// Exhaustive by construction: adding a stage to the union breaks this table, so a
// persisted-log validator can never silently start accepting an unknown stage.
export const RELAY_DIAL_STAGE_NAMES: Record<RelayDialStage, true> = {
opening: true,
'awaiting-hello': true,
handshaking: true,
confirming: true
}
// How long a dial spent in one stage. `complete` is false when the dial left the
// stage by dying in it, so a report can name the stage that never finished.
export type RelayDialStageTiming = {
stage: RelayDialStage
ms: number
complete: boolean
}
export type RelayDialStageSource = {
getDialStage(): RelayDialStage
onDialStageChange(listener: (stage: RelayDialStage) => void): () => void
@@ -27,8 +46,14 @@ export function relayDialStageSource(session: object): RelayDialStageSource | nu
export class RelayDialStageTracker implements RelayDialStageSource {
private stage: RelayDialStage = 'opening'
private stageEnteredAt: number
private settled = false
private readonly listeners = new Set<(stage: RelayDialStage) => void>()
constructor(private readonly now: () => number = monotonicNowMs) {
this.stageEnteredAt = now()
}
getDialStage(): RelayDialStage {
return this.stage
}
@@ -38,14 +63,34 @@ export class RelayDialStageTracker implements RelayDialStageSource {
return () => this.listeners.delete(listener)
}
advance(stage: RelayDialStage): void {
/** Returns the timing of the stage just left, or null when nothing was timed. */
advance(stage: RelayDialStage): RelayDialStageTiming | null {
if (this.stage === stage) {
return
return null
}
const now = this.now()
const timing = this.settled ? null : this.closeStage(true, now)
this.stage = stage
this.stageEnteredAt = now
for (const listener of this.listeners) {
listener(stage)
}
return timing
}
// Close the stage the dial is sitting in: `true` once it reached the runtime,
// `false` when it died there. Idempotent, so a failure on an already-connected
// session cannot re-time the last dial stage.
settle(complete: boolean): RelayDialStageTiming | null {
if (this.settled) {
return null
}
this.settled = true
return this.closeStage(complete, this.now())
}
private closeStage(complete: boolean, now: number): RelayDialStageTiming {
return { stage: this.stage, ms: elapsedMs(this.stageEnteredAt, now), complete }
}
}
@@ -0,0 +1,54 @@
import {
RpcSessionLivenessWatchdog,
type LivenessTimeoutEvidence
} from './rpc-session-liveness-watchdog'
import type { ConnectionLogSink } from './types'
// Ordinary foreground checks: two 4s misses, at most one voluntary probe per 10s.
const RELAY_PROBE = { timeoutMs: 4_000, missedProbeLimit: 2, minIntervalMs: 10_000 }
// A socket that died while the process was suspended must be admitted before the
// user reads the screen as broken. Two 2s misses, not one: the first frame after a
// resume rides a cold radio, and a single slow answer is not proof of a dead link.
const RELAY_RESUME_PROBE = { timeoutMs: 2_000, missedProbeLimit: 2 }
// Foreground-only sweep so a silently-dead relay surfaces without a user action.
const RELAY_IDLE_PROBE_MS = 25_000
// The relay session's probe budget and its timeout log line, kept apart from the
// session so the dial/RPC code and the liveness policy can each be read on its own.
export function createRelaySessionLivenessWatchdog(args: {
isForeground?: () => boolean
sendProbe: () => boolean
terminate: () => void
onLog?: ConnectionLogSink
nextLogId: () => string
}): RpcSessionLivenessWatchdog {
return new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: RELAY_IDLE_PROBE_MS,
probeTimeoutMs: RELAY_PROBE.timeoutMs,
missedProbeLimit: RELAY_PROBE.missedProbeLimit,
voluntaryProbeMinIntervalMs: RELAY_PROBE.minIntervalMs,
urgentProbeTimeoutMs: RELAY_RESUME_PROBE.timeoutMs,
urgentMissedProbeLimit: RELAY_RESUME_PROBE.missedProbeLimit,
shouldIdleProbe: () => args.isForeground?.() ?? true,
sendProbe: args.sendProbe,
onTimeout: (evidence: LivenessTimeoutEvidence) => {
// Why: the watchdog terminates the session right after this returns. A sink
// that throws must not keep a dead relay 'connected'.
try {
args.onLog?.({
id: args.nextLogId(),
ts: Date.now(),
level: 'error',
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
})
} catch {
// Diagnostics only.
}
},
terminate: args.terminate
})
}
@@ -1,3 +1,4 @@
import { elapsedMs, monotonicNowMs } from './monotonic-clock'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionState } from './types'
@@ -12,16 +13,19 @@ type ConnectionStateOptions = {
initialListener?: (state: ConnectionState) => void
getReconnectAttempt: () => number
isClosed: () => boolean
onStateDwell?: (previous: ConnectionState, next: ConnectionState, dweltMs: number) => void
now?: () => number
}
export class RpcClientConnectionState {
private state: ConnectionState = 'disconnected'
private lastConnectedAt: number | null = null
private stateEnteredAt = Date.now()
private stateEnteredAt: number
private readonly listeners = new Set<(state: ConnectionState) => void>()
private readonly waiters: ConnectWaiter[] = []
constructor(private readonly options: ConnectionStateOptions) {
this.stateEnteredAt = this.now()
if (options.initialListener) {
this.listeners.add(options.initialListener)
}
@@ -40,9 +44,14 @@ export class RpcClientConnectionState {
return
}
const previous = this.state
const dweltMs = Date.now() - this.stateEnteredAt
const dweltMs = elapsedMs(this.stateEnteredAt, this.now())
this.state = next
this.stateEnteredAt = Date.now()
this.stateEnteredAt = this.now()
try {
this.options.onStateDwell?.(previous, next, dweltMs)
} catch {
// Diagnostics only; a broken log sink must not abort the state publish.
}
console.log('[net] state', {
from: previous,
to: next,
@@ -103,6 +112,10 @@ export class RpcClientConnectionState {
return () => this.listeners.delete(listener)
}
private now(): number {
return (this.options.now ?? monotonicNowMs)()
}
private resolveWaiters(): void {
for (const waiter of this.waiters.splice(0)) {
if (waiter.timeout) {
@@ -67,7 +67,9 @@ describe('mobile rpc-client connection logs', () => {
onLog: (entry) => logs.push(entry)
})
expect(logs[0]?.detail).toBe('desktop.example:7443')
expect(logs).toContainEqual(
expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' })
)
expect(JSON.stringify(logs)).not.toContain('password')
client.close()
})
+23 -1
View File
@@ -58,6 +58,17 @@ export type ConnectionDiagnosticCode =
| 'relay-credential-unavailable'
| 'host-open-failed'
// Why: a 10s connect used to read as one opaque "connecting" span. Attaching the
// duration of the phase an entry closes out lets the report say where the time
// went. Diagnostics only — nothing schedules from these.
export type ConnectionLogTiming = {
kind: 'relay-dial-stage' | 'connection-state'
name: string
ms: number
// False when the phase never finished (the dial died inside it).
complete: boolean
}
export type ConnectionLogEntry = {
id: string
ts: number
@@ -68,6 +79,7 @@ export type ConnectionLogEntry = {
detail?: string
code?: ConnectionDiagnosticCode
path?: MobileConnectionDiagnosticPath
timing?: ConnectionLogTiming
}
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
@@ -76,7 +88,7 @@ export type ConnectionLogEmitter = (
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
evidence?: Pick<ConnectionLogEntry, 'code' | 'path' | 'timing'>
) => void
export type ConnectionState =
@@ -87,6 +99,16 @@ export type ConnectionState =
| 'reconnecting'
| 'auth-failed'
// Exhaustive by construction; see RELAY_DIAL_STAGE_NAMES for why.
export const CONNECTION_STATE_NAMES: Record<ConnectionState, true> = {
connecting: true,
handshaking: true,
connected: true,
disconnected: true,
reconnecting: true,
'auth-failed': true
}
// Why: a user-attention nudge must not tear down a healthy relay (probe it); only a
// network-change nudge marks the socket suspect enough to replace it.
export type ForegroundNudgeReason = 'focus' | 'app-resume' | 'network-change'
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined)
}))
const deletions = vi.hoisted(() => ({
deviceToken: vi.fn(async () => undefined),
credentialBundle: vi.fn(async () => undefined),
directUpgradeJournal: vi.fn(async () => undefined),
clearWriteRevision: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('./host-device-token-store', () => ({ deleteHostDeviceToken: deletions.deviceToken }))
vi.mock('./mobile-relay-credential-bundle', () => ({
deleteMobileRelayCredentialBundle: deletions.credentialBundle
}))
vi.mock('./mobile-relay-direct-upgrade-journal', () => ({
deleteMobileRelayDirectUpgradeJournal: deletions.directUpgradeJournal
}))
vi.mock('./host-credential-write-revision', () => ({
clearHostCredentialWriteRevision: deletions.clearWriteRevision,
getHostCredentialWriteRevision: () => 0
}))
import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion'
import {
getSessionTabStripCacheKey,
readCachedSessionTabStrip,
resetSessionTabStripCacheForTests,
saveCachedSessionTabStrip
} from '../cache/session-tab-strip-cache'
const strip = {
tabs: [{ id: 'tab-1', type: 'terminal' as const, title: 'Terminal', agentId: null }],
activeTabId: 'tab-1'
}
function createDeletion(storedHostIds: string[] = []) {
return createUnpairedHostCredentialDeletion({
waitForHostMutations: async () => undefined,
hasStoredHost: async (hostId) => storedHostIds.includes(hostId),
onDeleted: vi.fn()
})
}
beforeEach(() => {
asyncStorage.getItem.mockClear()
asyncStorage.setItem.mockClear()
for (const mock of Object.values(deletions)) {
mock.mockClear()
}
resetSessionTabStripCacheForTests()
})
describe('unpaired host credential deletion', () => {
it('takes the cached tab strip with the credentials, leaving other hosts alone', async () => {
// Why: the strip is not a credential, but it is host-scoped plaintext written from the
// session screen. Without this sweep it outlives the pairing that produced it.
const unpaired = getSessionTabStripCacheKey('host-1', 'wt-1')
const other = getSessionTabStripCacheKey('host-2', 'wt-1')
saveCachedSessionTabStrip(unpaired, strip)
saveCachedSessionTabStrip(other, strip)
await createDeletion()('host-1', 0)
expect(readCachedSessionTabStrip(unpaired)).toBeNull()
expect(readCachedSessionTabStrip(other)?.tabs).toHaveLength(1)
})
it('finishes the cleanup when the cache purge fails', async () => {
// Why: every credential above is already deleted by this point. Aborting on the cache
// would strand the write revision and leave onDeleted's token cache holding a host whose
// credentials are gone, retried only by an explicit Settings action.
const onDeleted = vi.fn()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full'))
await expect(
createUnpairedHostCredentialDeletion({
waitForHostMutations: async () => undefined,
hasStoredHost: async () => false,
onDeleted
})('host-1', 0)
).resolves.toBeUndefined()
expect(deletions.clearWriteRevision).toHaveBeenCalledWith('host-1')
expect(onDeleted).toHaveBeenCalledWith('host-1')
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
it('leaves the strip alone when the host turned out to still be paired', async () => {
const stillPaired = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(stillPaired, strip)
await createDeletion(['host-1'])('host-1', 0)
expect(readCachedSessionTabStrip(stillPaired)?.tabs).toHaveLength(1)
expect(deletions.deviceToken).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,4 @@
import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache'
import { deleteHostDeviceToken } from './host-device-token-store'
import {
clearHostCredentialWriteRevision,
@@ -52,6 +53,18 @@ export function createUnpairedHostCredentialDeletion(dependencies: DeletionDepen
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
// The cached tab strip is not a credential, but it is host-scoped plaintext that outlives
// the pairing unless this sweep takes it too. Warned rather than thrown, as
// removeHostAndCloseClient does: every credential above is already gone, so aborting here
// would strand the write revision and leave onDeleted's token cache holding a host whose
// credentials no longer exist. The cache refuses further saves for this host either way.
await deleteCachedSessionTabStripForHost(hostId).catch((error: unknown) => {
console.warn('[unpaired-host-cleanup] cached tab strip delete failed', error)
})
if (await shouldSkip(hostId, writeRevision)) {
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
clearHostCredentialWriteRevision(hostId)
dependencies.onDeleted(hostId)
}
+6 -6
View File
@@ -213,9 +213,9 @@ The commands, snapshot and ref rules, page affinity, and `browser_*` recoveries
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference from the same CLI build, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
| Action gate | Reference |
|---|---|
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
| Action gate | Reference |
| --------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
+17 -17
View File
@@ -52,23 +52,23 @@ Orca returns a clear message when the SDK is missing
Use `--json` for agent-driven calls. Unqualified commands target the worktree's active
device.
| Goal | Command | Constraint |
| ------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| List devices + AVDs | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach <avd-name-or-serial> --json` | Given an AVD name, boots it first. Makes the device active for the worktree. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Swipe / gesture | `ORCA emulator gesture '<json>' --json` | adb approximates the path by its endpoints, first point to last. |
| Type text | `ORCA emulator type "user@example.com" --json` | US-ASCII, spaces handled, no newlines. |
| Hardware button | `ORCA emulator button back --json` | `home`, `back`, `recents`, `power`, `volume_up`, `volume_down`. |
| Rotate | `ORCA emulator rotate landscape_left --json` | Sets `user_rotation` and disables auto-rotate. |
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --json` | `--reinstall` passes `-r`. |
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --json` | Omit `--activity` to launch the default LAUNCHER activity. |
| Runtime permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --json` | Positional order is `<grant\|revoke> <package> <permission>`; `reset` takes no positionals and clears all runtime grants. |
| Accessibility tree | `ORCA emulator ax --json` | `uiautomator dump` parsed to a node tree. |
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --json` | Dumps recent lines, parsed to entries. |
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --json` | Runs `adb -s <serial> shell <command>`. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the device down. |
| Goal | Command | Constraint |
| -------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| List devices + AVDs | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach <avd-name-or-serial> --json` | Given an AVD name, boots it first. Makes the device active for the worktree. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Swipe / gesture | `ORCA emulator gesture '<json>' --json` | adb approximates the path by its endpoints, first point to last. |
| Type text | `ORCA emulator type "user@example.com" --json` | US-ASCII, spaces handled, no newlines. |
| Hardware button | `ORCA emulator button back --json` | `home`, `back`, `recents`, `power`, `volume_up`, `volume_down`. |
| Rotate | `ORCA emulator rotate landscape_left --json` | Sets `user_rotation` and disables auto-rotate. |
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --json` | `--reinstall` passes `-r`. |
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --json` | Omit `--activity` to launch the default LAUNCHER activity. |
| Runtime permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --json` | Positional order is `<grant\|revoke> <package> <permission>`; `reset` takes no positionals and clears all runtime grants. |
| Accessibility tree | `ORCA emulator ax --json` | `uiautomator dump` parsed to a node tree. |
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --json` | Dumps recent lines, parsed to entries. |
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --json` | Runs `adb -s <serial> shell <command>`. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the device down. |
## Targeting
+15 -15
View File
@@ -43,20 +43,20 @@ Orca reports a clear error when the host is missing macOS or the Xcode tools.
Use `--json` for agent-driven calls. Unqualified commands target the worktree's active
device.
| Goal | Command | Constraint |
| ------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| List available / running | `ORCA emulator list --json` | Orca-managed sessions plus raw serve-sim streams. Use its ids for `--device` / `--emulator`. |
| List devices everywhere | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" --json` | Starts the helper if needed and makes the device active for the worktree. `--focus` switches the UI; it does not by default. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Multi-step gesture | `ORCA emulator gesture '<json>' --json` | Begin/move/end points. Use `tap` for a single tap. |
| Type text | `ORCA emulator type "text" --json` | US-ASCII only. |
| Goal | Command | Constraint |
| ------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List available / running | `ORCA emulator list --json` | Orca-managed sessions plus raw serve-sim streams. Use its ids for `--device` / `--emulator`. |
| List devices everywhere | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" --json` | Starts the helper if needed and makes the device active for the worktree. `--focus` switches the UI; it does not by default. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Multi-step gesture | `ORCA emulator gesture '<json>' --json` | Begin/move/end points. Use `tap` for a single tap. |
| Type text | `ORCA emulator type "text" --json` | US-ASCII only. |
| Hardware button | `ORCA emulator button home --json` | `home` and `side_button` are documented by the CLI spec; other names such as `swipe_home`, `app_switcher`, `lock`, and `siri` are forwarded to serve-sim unvalidated. |
| Rotate device | `ORCA emulator rotate landscape_left --json` | The orientation persists for subsequent gestures. |
| Accessibility tree | `ORCA emulator ax --json` | serve-sim node tree, capped at 500 nodes, frames normalized 0..1 with a top-left origin. Needs an active session. |
| Raw passthrough | `ORCA emulator exec --command "ca-debug blended on" --json` | serve-sim subcommand string, without a `serve-sim` prefix. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the simulator device down. |
| Rotate device | `ORCA emulator rotate landscape_left --json` | The orientation persists for subsequent gestures. |
| Accessibility tree | `ORCA emulator ax --json` | serve-sim node tree, capped at 500 nodes, frames normalized 0..1 with a top-left origin. Needs an active session. |
| Raw passthrough | `ORCA emulator exec --command "ca-debug blended on" --json` | serve-sim subcommand string, without a `serve-sim` prefix. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the simulator device down. |
## Targeting
@@ -65,8 +65,8 @@ commands target it. Pass a selector only to override that or reach a second devi
active session an unqualified command fails with `emulator_no_active`; attach or open the pane
and retry.
- `--device "iPhone 16 Pro"` or `--device <udid>`, from `list` or `devices`. `--emulator
<id>` is an alternative spelling: the bridge resolves both through the same lookup. These
- `--device "iPhone 16 Pro"` or `--device <udid>`, from `list` or `devices`.
`--emulator <id>` is an alternative spelling: the bridge resolves both through the same lookup. These
selectors apply to the action verbs; `list` and `devices` take only `--worktree`, and
`attach` names its device as a positional argument.
- `--worktree id:<fullWorktreeId>` or `--worktree active`. The full id is the exact
+7 -7
View File
@@ -367,10 +367,10 @@ rejects `--reference`, run `ORCA skills get orca-per-workspace-env --full` once
this guide plus every reference from the same CLI build, so read only the named one. If `--full` is
rejected too, keep these rules, use the command's `--help`, and do not guess flags.
| Action gate | Bundled reference |
| --- | --- |
| Writing the base-snapshot, auth, or create script for a snapshot-capable cloud provider | `references/provider-vercel.md` |
| The recipe connects over SSH instead of starting `orca serve`, including provisioned root | `references/ssh-host.md` |
| The environment is a local Docker container reached over SSH | `references/docker-ssh.md` |
| The user's desktop is Windows and you are scaffolding local-side scripts | `references/windows-scripts.md` |
| A doctor, provision, clone, login, or snapshot step failed | `references/failure-modes.md` |
| Action gate | Bundled reference |
| ----------------------------------------------------------------------------------------- | ------------------------------- |
| Writing the base-snapshot, auth, or create script for a snapshot-capable cloud provider | `references/provider-vercel.md` |
| The recipe connects over SSH instead of starting `orca serve`, including provisioned root | `references/ssh-host.md` |
| The environment is a local Docker container reached over SSH | `references/docker-ssh.md` |
| The user's desktop is Windows and you are scaffolding local-side scripts | `references/windows-scripts.md` |
| A doctor, provision, clone, login, or snapshot step failed | `references/failure-modes.md` |
File diff suppressed because one or more lines are too long
@@ -24,7 +24,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
['main/runtime/relay/relay-http-client.ts', 2],
['main/runtime/relay/relay-region-preference.ts', 3],
['main/runtime/relay/relay-region-catalog-fetch.ts', 1],
['main/runtime/relay/relay-region-preference.ts', 2],
['main/runtime/relay/relay-region-probe.ts', 1],
['main/source-control/hosted-review-api-request.ts', 1],
['main/speech/openai-transcription-client.ts', 1],
+18 -1
View File
@@ -742,11 +742,28 @@ describe('registerMobileHandlers', () => {
})
it('reports the current relay broker status without exposing a toggle', () => {
registerMobileHandlers({} as never, { getRelayStatus: () => 'registered' })
registerMobileHandlers({} as never, { getRelayStatus: () => ({ status: 'registered' }) })
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' })
})
it('reports the assigned relay cell alongside the status', () => {
registerMobileHandlers({} as never, {
getRelayStatus: () => ({ status: 'registered', cellUrl: 'https://c27.relay.example.com' })
})
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({
status: 'registered',
cellUrl: 'https://c27.relay.example.com'
})
})
it('falls back to offline with no cell when no relay status provider is wired', () => {
registerMobileHandlers({} as never, {})
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'offline' })
})
it('consumes a pending auth-failure notification only from a window renderer', () => {
const consumePendingUnpairedDeviceAuthFailure = vi.fn(() => true)
registerMobileHandlers({} as never, { consumePendingUnpairedDeviceAuthFailure })
+6 -5
View File
@@ -13,7 +13,7 @@ import {
} from '../runtime/pairing-network-interfaces'
import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr'
import { getWindowsDefaultRouteInterfaceNames } from '../runtime/windows-default-route-interfaces'
import {
@@ -51,7 +51,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
export type MobileHandlerDependencies = {
firewallEnvironment?: WindowsMobileFirewallEnvironment
openWindowsNetworkSettings?: () => Promise<void>
getRelayStatus?: () => RelayBrokerStatus
getRelayStatus?: () => MobileRelayStatusDetail
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
encodePairingQr?: (pairingUrl: string) => Promise<MobilePairingQrResult>
getDefaultRouteInterfaceNames?: DefaultRouteInterfaceLookup
@@ -287,9 +287,10 @@ export function registerMobileHandlers(
return true
})
ipcMain.handle('mobile:getRelayStatus', () => ({
status: dependencies.getRelayStatus?.() ?? 'offline'
}))
ipcMain.handle(
'mobile:getRelayStatus',
(): MobileRelayStatusDetail => dependencies.getRelayStatus?.() ?? { status: 'offline' }
)
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', (event) => {
if (!isWindowRenderer(event)) {
@@ -26,7 +26,7 @@ type DesktopRelayServiceOptions = {
userDataPath: string
appVersion: string
runtimeRpc: OrcaRuntimeRpcServer
onStatus: (status: RelayBrokerStatus) => void
onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void
}
export function pairingAuthorizationForContext(
@@ -31,6 +31,50 @@ describe('RelayAuthCoordinator', () => {
expect(statuses.at(-1)).toBe('standby')
})
it('republishes the owned broker cell instead of blanking what the broker set', async () => {
const broker = { closeNow: vi.fn(), endpoint: { cellUrl: 'https://c27.relay.example.test' } }
const onStatus = vi.fn()
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
openBroker: async () => broker,
onStatus
})
coordinator.reconcile()
await coordinator.waitForLiveBroker()
// Why: the broker announces its cell, then the coordinator republishes the
// same status; a republish without the cell would erase it immediately.
expect(onStatus).toHaveBeenLastCalledWith('registered', 'https://c27.relay.example.test')
coordinator.reconcile()
await coordinator.waitForLiveBroker()
expect(onStatus).toHaveBeenLastCalledWith('registered', 'https://c27.relay.example.test')
})
it('drops the cell from every status the host is not served on', async () => {
let demanded = true
const broker = { closeNow: vi.fn(), endpoint: { cellUrl: 'https://c27.relay.example.test' } }
const onStatus = vi.fn()
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
hasDemand: () => demanded,
openBroker: async () => broker,
onStatus,
lingerMs: 0
})
coordinator.reconcile()
await coordinator.waitForLiveBroker()
demanded = false
coordinator.reconcile()
await vi.waitFor(() => expect(onStatus).toHaveBeenLastCalledWith('standby', undefined))
coordinator.fenceAndCloseNow()
expect(onStatus).toHaveBeenLastCalledWith('offline', undefined)
for (const [status, cellUrl] of onStatus.mock.calls) {
expect(status === 'registered' || cellUrl === undefined).toBe(true)
}
})
it('opens on demand and lingers before closing the last control', async () => {
let demanded = false
const broker = { closeNow: vi.fn() }
@@ -2,6 +2,7 @@ import {
RELAY_HOST_CLOSE_REASON,
type RelayHostCloseReason
} from '../../../shared/relay-host-close-reason'
import { relayStatusCellUrl } from '../../../shared/mobile-relay-status'
import type { RelayBrokerStatus } from './relay-session-broker'
import { RelayHttpError, shouldRetryRelayConnectionError } from './relay-http-client'
@@ -20,6 +21,7 @@ export type RelayAuthContext = {
export type CoordinatedRelayBroker = {
closeNow(hostCloseReason?: RelayHostCloseReason): void
isLive?(): boolean
readonly endpoint?: { cellUrl: string } | null
}
type RelayAuthCoordinatorOptions = {
@@ -30,7 +32,7 @@ type RelayAuthCoordinatorOptions = {
isCurrent: () => boolean
refreshAccessToken: () => Promise<string | null>
}) => Promise<CoordinatedRelayBroker>
onStatus: (status: RelayBrokerStatus) => void
onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void
lingerMs?: number
random?: () => number
}
@@ -92,7 +94,17 @@ export class RelayAuthCoordinator {
this.retryAttempt = 0
this.invalidatePendingOwnerships()
this.invalidateOwnership(hostCloseReason)
this.options.onStatus('offline')
this.publish('offline')
}
// Why derived rather than passed in: the coordinator republishes `registered`
// after the broker already announced its cell, so a call site that forgot the
// cell would silently blank it moments after the broker set it.
private publish(status: RelayBrokerStatus): void {
this.options.onStatus(
status,
relayStatusCellUrl(status, this.ownership?.broker?.endpoint?.cellUrl)
)
}
// Raw ownership handle for identity matching (revoke routing); control work uses getLiveBroker.
@@ -158,13 +170,13 @@ export class RelayAuthCoordinator {
// by a 401). A present-but-unentitled context is still a signed-in
// desktop, and "sign in to reconnect" would be wrong advice for it.
this.invalidateOwnership(context ? undefined : RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
this.options.onStatus('offline')
this.publish('offline')
return
}
const nextIdentityKey = identityKey(context.identity)
if (expectedIdentityKey && nextIdentityKey !== expectedIdentityKey) {
this.retryAttempt = 0
this.options.onStatus('offline')
this.publish('offline')
return
}
if (!(this.options.hasDemand?.(context) ?? true)) {
@@ -175,7 +187,7 @@ export class RelayAuthCoordinator {
} else if (this.ownership?.valid) {
this.scheduleLinger(context, this.ownership)
}
this.options.onStatus('standby')
this.publish('standby')
return
}
this.cancelLinger()
@@ -187,12 +199,12 @@ export class RelayAuthCoordinator {
(this.ownership.broker?.isLive?.() ?? true)
) {
this.retryAttempt = 0
this.options.onStatus('registered')
this.publish('registered')
return
}
retryIdentityKey = nextIdentityKey
this.invalidateOwnership()
this.options.onStatus('connecting')
this.publish('connecting')
const ownership: BrokerOwnership = {
identityKey: nextIdentityKey,
broker: null,
@@ -220,7 +232,7 @@ export class RelayAuthCoordinator {
}
this.ownership = ownership
this.retryAttempt = 0
this.options.onStatus('registered')
this.publish('registered')
} catch (error) {
if (this.isEpochCurrent(epoch)) {
// Why: silent broker-open failures made a dead relay look like standby
@@ -229,7 +241,7 @@ export class RelayAuthCoordinator {
'[relay] broker reconcile failed:',
error instanceof Error ? error.message : String(error)
)
this.options.onStatus('offline')
this.publish('offline')
if (shouldRetryRelayConnectionError(error)) {
const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0
this.scheduleRetry(epoch, retryIdentityKey, retryAfterMs)
@@ -304,7 +316,7 @@ export class RelayAuthCoordinator {
!(this.options.hasDemand?.(context) ?? true)
) {
this.invalidateOwnership()
this.options.onStatus('standby')
this.publish('standby')
}
}, lingerMs)
}
@@ -185,17 +185,21 @@ describe('RelayControlClient', () => {
.update(hostKeys.publicKey)
.digest('base64url')
.slice(0, 16)
const accepted = new Promise<{ socket: WebSocket; authorization: string; path: string }>(
(resolve) => {
server.once('connection', (socket, request) =>
resolve({
socket,
authorization: String(request.headers.authorization),
path: request.url ?? ''
})
)
}
)
const accepted = new Promise<{
socket: WebSocket
authorization: string
capabilities: string
path: string
}>((resolve) => {
server.once('connection', (socket, request) =>
resolve({
socket,
authorization: String(request.headers.authorization),
capabilities: String(request.headers['x-orca-host-capabilities']),
path: request.url ?? ''
})
)
})
const onConnectionOpen = vi.fn()
const onDrain = vi.fn()
const onClose = vi.fn()
@@ -213,8 +217,11 @@ describe('RelayControlClient', () => {
})
clients.push(client)
const connecting = client.connect()
const { socket, authorization, path } = await accepted
const { socket, authorization, capabilities, path } = await accepted
expect(authorization).toBe('Bearer scoped-token')
// Advertised on the upgrade, never in host-hello: a cell that predates the
// capability parses host-hello strictly and would refuse the handshake.
expect(capabilities).toBe('pending-conn-details')
expect(path).toBe('/v1/host/control')
const hello = await nextJson(socket)
expect(hello).toMatchObject({
@@ -411,6 +418,7 @@ class FakeControlSocket extends EventEmitter {
function scriptedControl(options: { closeWithAck?: boolean; issuedAtOffsetMs?: number } = {}): {
client: RelayControlClient
socket: FakeControlSocket
onConnectionOpen: ReturnType<typeof vi.fn>
onClose: ReturnType<typeof vi.fn>
} {
const hostKeys = nacl.box.keyPair()
@@ -478,6 +486,7 @@ function scriptedControl(options: { closeWithAck?: boolean; issuedAtOffsetMs?: n
}
}
const onClose = vi.fn()
const onConnectionOpen = vi.fn()
const client = new RelayControlClient({
cellUrl: origin,
relayJwt: 'scoped-token',
@@ -486,13 +495,13 @@ function scriptedControl(options: { closeWithAck?: boolean; issuedAtOffsetMs?: n
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
keypair,
appVersion: '1.2.3',
onConnectionOpen: vi.fn(),
onConnectionOpen,
onDrain: vi.fn(),
onClose,
createSocket: () => socket as unknown as WebSocket
})
queueMicrotask(() => socket.emit('open'))
return { client, socket, onClose }
return { client, socket, onConnectionOpen, onClose }
}
describe('RelayControlClient scripted-socket lifecycle', () => {
@@ -585,6 +594,29 @@ describe('RelayControlClient scripted-socket lifecycle', () => {
warn.mockRestore()
})
it('still opens a connection the relay handed over before it asked us to drain', async () => {
const { client, socket, onConnectionOpen } = scriptedControl()
await client.connect()
socket.deliver({ type: 'drain', graceMs: 5_000, recovery: 'resolve-director' })
// A drain-only cell refuses new phones, so this conn-open was issued before
// the drain and only this cell holds the phone waiting on it.
socket.deliver({
type: 'conn-open',
connId: 'conn-1',
connTicket: 'T'.repeat(43),
kind: 'resume',
relayDeviceId: 'device-1',
attachDeadlineMs: 10_000
})
expect(onConnectionOpen).toHaveBeenCalledOnce()
expect(onConnectionOpen).toHaveBeenCalledWith(
expect.objectContaining({ connId: 'conn-1', connTicket: 'T'.repeat(43) })
)
expect(client.isLive()).toBe(true)
})
it('still tears down a malformed (non-JSON) control frame', async () => {
const { client, socket, onClose } = scriptedControl()
await client.connect()
@@ -9,6 +9,7 @@ import {
RelayHostChallengeMessageSchema,
RelayHostHelloAckMessageSchema,
RelayPingMessageSchema,
RELAY_HOST_CAPABILITY_HEADERS,
encodeRelayHostHello,
parseRelayControlMessage,
type RelayConnectionOpenMessage,
@@ -74,7 +75,7 @@ export class RelayControlClient {
options.createSocket ??
((url, token) =>
new WebSocket(url, {
headers: { authorization: `Bearer ${token}` },
headers: { authorization: `Bearer ${token}`, ...RELAY_HOST_CAPABILITY_HEADERS },
perMessageDeflate: false,
maxPayload: 64 * 1024
}))
@@ -215,7 +216,10 @@ export class RelayControlClient {
return
}
const connection = RelayConnectionOpenMessageSchema.safeParse(message)
if (connection.success && this.state === 'active') {
if (connection.success) {
// Also while draining: a drain-only cell refuses new phones, so a conn-open
// arriving after drain was issued before it and only this cell holds that
// pending connection. Dropping it stranded the phone until its attach deadline.
this.options.onConnectionOpen(connection.data)
return
}
@@ -0,0 +1,247 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
RELAY_HOST_ATTACH_DEADLINE_MS,
type RelayConnectionOpenMessage,
type RelayHostHelloAckMessage
} from './relay-control-protocol'
import type { RelayAssignment } from './relay-http-client'
const fakes = vi.hoisted(() => ({
controls: [] as {
options: {
previousGeneration?: number
controlResumeSecret?: string
onConnectionOpen(message: RelayConnectionOpenMessage): void
onDrain(message: { type: 'drain'; graceMs: number; recovery: 'resolve-director' }): void
onClose(code: number): void
}
}[],
transports: [] as {
openConnections: Set<string>
openConnection: ReturnType<typeof vi.fn>
hasConnection: ReturnType<typeof vi.fn>
}[],
controlConnect: vi.fn()
}))
vi.mock('./relay-control-client', () => ({
RelayControlClient: class {
connect = fakes.controlConnect
closeNow = vi.fn()
isLive = vi.fn(() => true)
pendingRequestCount = 0
constructor(readonly options: (typeof fakes.controls)[number]['options']) {
fakes.controls.push(this)
}
}
}))
vi.mock('../rpc/relay-transport', () => ({
CloudRelayTransport: class {
readonly openConnections = new Set<string>()
start = vi.fn().mockResolvedValue(undefined)
stop = vi.fn().mockResolvedValue(undefined)
setGeneration = vi.fn()
metadataFor = vi.fn()
hasConnection = vi.fn((connectionId: string) => this.openConnections.has(connectionId))
openConnection = vi.fn(async (connection: RelayConnectionOpenMessage) => {
this.openConnections.add(connection.connId)
})
constructor() {
fakes.transports.push(this)
}
}
}))
import { RelayControlOrigin } from './relay-control-origin'
const ASSIGNMENT: RelayAssignment = {
v: 1,
cellUrl: 'https://relay.example.test',
assignmentEpoch: 1,
lease: 'lease-token'
}
const TICKET = 'T'.repeat(43)
function ack(overrides: Partial<RelayHostHelloAckMessage> = {}): RelayHostHelloAckMessage {
return {
type: 'host-hello-ack',
v: 1,
generation: 7,
controlResumeSecret: 'R'.repeat(43),
leaseExpiresAt: 1_000_000,
activeConnIds: [],
pendingConns: [],
...overrides
}
}
function connOpen(overrides: Partial<RelayConnectionOpenMessage> = {}): RelayConnectionOpenMessage {
return {
type: 'conn-open',
connId: 'conn-1',
connTicket: TICKET,
kind: 'resume',
relayDeviceId: 'device-1',
attachDeadlineMs: 10_000,
...overrides
}
}
function createOrigin(): {
origin: RelayControlOrigin
owned: string[]
released: string[]
} {
const keypair = nacl.box.keyPair()
const owned: string[] = []
const released: string[] = []
const origin = new RelayControlOrigin({
assignment: ASSIGNMENT,
relayJwt: 'relay-jwt',
relayHostId: 'host-1',
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
keypair: { ...keypair, publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') },
appVersion: '1.0.0',
mobileSocketWiring: { attachTransport: vi.fn(() => () => {}) } as never,
onConnectionOwned: (connectionId) => owned.push(connectionId),
onConnectionReleased: (connectionId) => released.push(connectionId),
onDrain: vi.fn(),
onClose: vi.fn()
})
return { origin, owned, released }
}
describe('RelayControlOrigin pending-connection replay', () => {
beforeEach(() => {
fakes.controls.length = 0
fakes.transports.length = 0
fakes.controlConnect.mockReset()
})
it('pins the attach deadline this file mirrors from the relay contract', () => {
// Hand-mirrored from RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs, which the
// contract suite pins to the same literal. Drift would silently shorten the
// observed-open eviction window and the deadline a replayed dial states.
expect(RELAY_HOST_ATTACH_DEADLINE_MS).toBe(10_000)
})
it('dials a pending connection the ack restates in full, without waiting on a timer', async () => {
fakes.controlConnect.mockResolvedValue(
ack({
pendingConns: [
{ connId: 'conn-1', connTicket: TICKET, kind: 'invite', relayDeviceId: 'device-1' }
]
})
)
const { origin, owned } = createOrigin()
await origin.open()
expect(fakes.transports[0]!.openConnection).toHaveBeenCalledOnce()
expect(fakes.transports[0]!.openConnection).toHaveBeenCalledWith({
type: 'conn-open',
connId: 'conn-1',
connTicket: TICKET,
kind: 'invite',
relayDeviceId: 'device-1',
attachDeadlineMs: 10_000
})
expect(owned).toEqual(['conn-1'])
})
it('replays a pending connection the relay only identified, reusing the observed conn-open', async () => {
fakes.controlConnect
.mockResolvedValueOnce(ack())
.mockResolvedValueOnce(ack({ pendingConns: [{ connId: 'conn-1', connTicket: TICKET }] }))
const { origin } = createOrigin()
await origin.open()
fakes.controls[0]!.options.onConnectionOpen(connOpen())
// The blip that costs the control also kills the in-flight data socket.
fakes.transports[0]!.openConnections.delete('conn-1')
await origin.rebind('relay-jwt', ASSIGNMENT)
expect(fakes.transports[0]!.openConnection).toHaveBeenCalledTimes(2)
expect(fakes.transports[0]!.openConnection).toHaveBeenLastCalledWith({
type: 'conn-open',
connId: 'conn-1',
connTicket: TICKET,
kind: 'resume',
relayDeviceId: 'device-1',
attachDeadlineMs: 10_000
})
})
it('never re-dials a pending connection that is already owned or open', async () => {
fakes.controlConnect.mockResolvedValueOnce(ack()).mockResolvedValueOnce(
ack({
activeConnIds: ['conn-active'],
pendingConns: [
{ connId: 'conn-active', connTicket: TICKET, kind: 'resume', relayDeviceId: 'device-1' },
{ connId: 'conn-1', connTicket: TICKET, kind: 'resume', relayDeviceId: 'device-1' }
]
})
)
const { origin } = createOrigin()
await origin.open()
fakes.controls[0]!.options.onConnectionOpen(connOpen())
expect(fakes.transports[0]!.openConnection).toHaveBeenCalledOnce()
await origin.rebind('relay-jwt', ASSIGNMENT)
// conn-active is spliced already and conn-1 still holds its data socket.
expect(fakes.transports[0]!.openConnection).toHaveBeenCalledOnce()
})
it('skips a pending connection no control ever described', async () => {
// Documents the contract gap: pendingConns entries carry only the
// identifiers, and a dial without the relay's kind/device would guess at
// both the pairing authority and the E2EE device binding.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fakes.controlConnect.mockResolvedValue(
ack({ pendingConns: [{ connId: 'conn-unknown', connTicket: TICKET }] })
)
const { origin, owned } = createOrigin()
await origin.open()
expect(fakes.transports[0]!.openConnection).not.toHaveBeenCalled()
expect(owned).toEqual([])
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('dials nothing once the origin is closed', async () => {
fakes.controlConnect.mockResolvedValue(ack())
const { origin, owned } = createOrigin()
await origin.open()
await origin.close()
// A conn-open still in flight when teardown ran must not open a data socket
// that nothing is left to close.
fakes.controls[0]!.options.onConnectionOpen(connOpen({ connId: 'conn-late' }))
expect(fakes.transports[0]!.openConnection).not.toHaveBeenCalled()
expect(owned).toEqual([])
})
it('releases a replayed connection whose dial fails', async () => {
fakes.controlConnect.mockResolvedValue(
ack({
pendingConns: [
{ connId: 'conn-1', connTicket: TICKET, kind: 'resume', relayDeviceId: 'device-1' }
]
})
)
const { origin, released } = createOrigin()
fakes.transports[0]!.openConnection.mockRejectedValue(new Error('relay_transport_stopped'))
await origin.open()
await vi.waitFor(() => expect(released).toEqual(['conn-1']))
})
})
+83 -10
View File
@@ -3,15 +3,19 @@ import type { E2EEKeypair } from '../e2ee-keypair'
import { CloudRelayTransport } from '../rpc/relay-transport'
import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring'
import { RelayControlClient } from './relay-control-client'
import { RELAY_HOST_ATTACH_DEADLINE_MS } from './relay-control-protocol'
import type {
RelayConnectionOpenMessage,
RelayDrainMessage,
RelayHostHelloAckMessage
RelayHostHelloAckMessage,
RelayPendingConnection
} from './relay-control-protocol'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
import type { RelayIdentity } from './relay-session-broker-contract'
import type { RelayAssignment } from './relay-http-client'
const OBSERVED_OPEN_LIMIT = 16
type RelayControlOriginOptions = {
assignment: RelayAssignment
relayJwt: string
@@ -41,8 +45,14 @@ export class RelayControlOrigin {
private generation = 0
private controlResumeSecret: string | null = null
private leaseExpiresAt = 0
private acceptingConnections = true
private closed = false
// conn-opens seen on any control of this origin, kept for the cell's attach
// window so a replayed pending connection keeps the relay's own kind/device
// when the ack does not restate it (a cell that predates that field).
private readonly observedOpens = new Map<
string,
{ message: RelayConnectionOpenMessage; seenAt: number }
>()
private readonly detachMobileSocketTransport: () => void
constructor(options: RelayControlOriginOptions) {
@@ -114,7 +124,6 @@ export class RelayControlOrigin {
controlResumeSecret: this.controlResumeSecret
})
this.activate(control, ack)
this.acceptingConnections = true
// Why: the resumed control owns the same server generation and splices;
// the predecessor remains only long enough for any idempotent reply in flight.
if (previous && previous.pendingRequestCount === 0) {
@@ -129,12 +138,6 @@ export class RelayControlOrigin {
}
}
markDraining(): void {
// The relay changes the control's protocol state when it sends drain. This
// marker exists for the broker's ownership policy, not a second wire event.
this.acceptingConnections = false
}
refreshAuthorization(relayJwt: string): void {
for (const control of this.controls) {
try {
@@ -159,6 +162,7 @@ export class RelayControlOrigin {
}
this.controls.clear()
this.activeControl = null
this.observedOpens.clear()
try {
await this.transport.stop()
} finally {
@@ -248,15 +252,84 @@ export class RelayControlOrigin {
for (const connectionId of ack.activeConnIds) {
this.options.onConnectionOwned(connectionId, this)
}
this.replayPendingConnections(ack)
}
// The cell sends conn-open once. A control that rotates or rebinds mid-accept
// restates the still-waiting connections here instead, and without this replay
// the phone waits out its attach deadline and is closed as if the host were offline.
private replayPendingConnections(ack: RelayHostHelloAckMessage): void {
const active = new Set(ack.activeConnIds)
for (const pending of ack.pendingConns) {
if (active.has(pending.connId) || this.transport.hasConnection(pending.connId)) {
continue
}
const message = this.pendingConnectionOpen(pending)
if (!message) {
console.warn('[relay] pending connection not replayable: relay stated no kind/device')
continue
}
// Not remembered: a replay must not extend the observed entry's own life.
this.dialConnection(message)
}
}
private pendingConnectionOpen(
pending: RelayPendingConnection
): RelayConnectionOpenMessage | null {
// A pending entry may restate only the identifiers. kind and relayDeviceId
// decide local pairing authority and E2EE device binding, so they are taken
// from the relay — the ack itself, or the conn-open this process already saw.
const observed = this.observedOpens.get(pending.connId)?.message
const kind = pending.kind ?? observed?.kind
const relayDeviceId = pending.relayDeviceId ?? observed?.relayDeviceId
if (!kind || !relayDeviceId) {
return null
}
return {
type: 'conn-open',
connId: pending.connId,
connTicket: pending.connTicket,
kind,
relayDeviceId,
// The cell's attach timer started before this control existed, so the real
// remaining budget is unknown and never longer than the contract deadline.
attachDeadlineMs: RELAY_HOST_ATTACH_DEADLINE_MS
}
}
private openConnection(message: RelayConnectionOpenMessage): void {
if (!this.acceptingConnections) {
if (this.closed) {
return
}
this.rememberOpen(message)
this.dialConnection(message)
}
private dialConnection(message: RelayConnectionOpenMessage): void {
this.options.onConnectionOwned(message.connId, this)
void this.transport.openConnection(message).catch(() => {
this.options.onConnectionReleased(message.connId, this)
})
}
private rememberOpen(message: RelayConnectionOpenMessage): void {
const now = Date.now()
for (const [connId, entry] of this.observedOpens) {
// Past the attach deadline the cell has already failed the connection.
if (now - entry.seenAt > RELAY_HOST_ATTACH_DEADLINE_MS) {
this.observedOpens.delete(connId)
}
}
// The contract caps a session at 8 connections; the surplus is a clock that
// never advanced, so drop oldest-first rather than growing without bound.
while (this.observedOpens.size >= OBSERVED_OPEN_LIMIT) {
const oldest = this.observedOpens.keys().next()
if (oldest.done) {
break
}
this.observedOpens.delete(oldest.value)
}
this.observedOpens.set(message.connId, { message, seenAt: now })
}
}
@@ -26,8 +26,28 @@ export const RelayHostChallengeMessageSchema = z
})
.strict()
const ConnectionKindSchema = z.enum(['invite', 'resume'])
// Mirrors RELAY_HOST_CAPABILITIES_HEADER in the relay contract. It rides the
// control upgrade rather than host-hello because the cell parses host-hello
// strictly: a new hello key is refused by every already-deployed cell.
export const RELAY_HOST_CAPABILITY_HEADERS = {
'x-orca-host-capabilities': 'pending-conn-details'
} as const
// Mirrors RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs in the relay contract: the
// window the cell keeps a phone waiting for the host's data socket.
export const RELAY_HOST_ATTACH_DEADLINE_MS = 10_000
// kind/relayDeviceId are accepted but not required: today's cells restate only
// the identifiers, and a strict schema would make adding them a breaking change.
const PendingConnectionSchema = z
.object({ connId: OpaqueIdSchema, connTicket: Base64Url32ByteSchema })
.object({
connId: OpaqueIdSchema,
connTicket: Base64Url32ByteSchema,
kind: ConnectionKindSchema.optional(),
relayDeviceId: OpaqueIdSchema.optional()
})
.strict()
export const RelayHostHelloAckMessageSchema = z
@@ -47,7 +67,7 @@ export const RelayConnectionOpenMessageSchema = z
type: z.literal('conn-open'),
connId: OpaqueIdSchema,
connTicket: Base64Url32ByteSchema,
kind: z.enum(['invite', 'resume']),
kind: ConnectionKindSchema,
relayDeviceId: OpaqueIdSchema,
attachDeadlineMs: z.number().int().positive().max(60_000)
})
@@ -119,6 +139,7 @@ export const RelayControlErrorMessageSchema = z
})
.strict()
export type RelayPendingConnection = z.infer<typeof PendingConnectionSchema>
export type RelayHostHelloAckMessage = z.infer<typeof RelayHostHelloAckMessageSchema>
export type RelayConnectionOpenMessage = z.infer<typeof RelayConnectionOpenMessageSchema>
export type RelayDrainMessage = z.infer<typeof RelayDrainMessageSchema>
@@ -144,7 +144,6 @@ export class RelayOriginPool {
if (!this.isCurrent() || origin !== this.activeOrigin) {
return
}
origin.markDraining()
this.drainingOrigins.add(origin)
this.options.onStatus('draining')
if (!this.rotationPromise && !this.drainRetry.pending) {
@@ -0,0 +1,64 @@
import { cancelUnreadResponseBody } from '../../lib/unread-response-body'
import { readFetchResponseJsonWithinLimit } from '../../../shared/fetch-response-body'
import { RelayRegionCatalogSchema, type RelayRegionCatalog } from './relay-region-probe'
const CATALOG_MAX_BYTES = 16 * 1024
export async function fetchRelayRegionCatalog(
directorUrl: string,
fetch: typeof globalThis.fetch,
timeoutMs: number
): Promise<RelayRegionCatalog> {
if (!isCanonicalDirectorOrigin(directorUrl)) {
throw new Error('invalid relay director origin')
}
const response = await fetch(`${directorUrl}/v1/regions`, {
method: 'GET',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
throw new Error(`relay region catalog failed (${response.status})`)
}
const body = await readFetchResponseJsonWithinLimit<unknown>(response, CATALOG_MAX_BYTES, {
structuralTokens: 64,
nestingDepth: 8
})
const catalog = RelayRegionCatalogSchema.parse(body)
if (
catalog.regions.some((entry) =>
entry.probeOrigins.some((origin) => !isProbeOriginForDirector(origin, directorUrl))
)
) {
throw new Error('relay probe origin does not belong to the director')
}
return catalog
}
// Logs name the director by host so staging and production lines stay
// distinguishable without carrying a full URL through every event.
export function relayDirectorHost(directorUrl: string): string {
try {
return new URL(directorUrl).hostname
} catch {
return 'invalid'
}
}
function isCanonicalDirectorOrigin(value: string): boolean {
try {
const url = new URL(value)
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
return (
url.origin === value && (url.protocol === 'https:' || (url.protocol === 'http:' && loopback))
)
} catch {
return false
}
}
function isProbeOriginForDirector(origin: string, directorUrl: string): boolean {
return new URL(origin).hostname.endsWith(`.${new URL(directorUrl).hostname}`)
}
@@ -504,6 +504,33 @@ describe('Relay region cache self-heal', () => {
expect(existsSync(cachePath(path))).toBe(false)
})
it('reports a director that cannot list its regions instead of failing silently', async () => {
const path = userDataPath()
writeCache(path, 'asia-east2', LIVE_EXPIRY)
const events: unknown[] = []
const resolver = new RelayRegionPreferenceResolver({
directorUrl: DIRECTOR,
userDataPath: path,
fetch: vi.fn<typeof globalThis.fetch>(async () => {
throw new Error('director offline')
}),
now: () => 1_000,
logEvent: (event) => events.push(event)
})
await resolver.invalidateIfAssignedCellIsFar(CELL)
expect(existsSync(cachePath(path))).toBe(true)
expect(events).toEqual([
expect.objectContaining({
event: 'relay_region_self_heal',
cachedRegion: 'asia-east2',
assignedCellUrl: CELL,
decision: 'kept',
reason: 'catalog-unavailable'
})
])
})
it('probes a given cell only once per process', async () => {
const path = userDataPath()
writeCache(path, 'asia-east2', LIVE_EXPIRY)
@@ -2,28 +2,37 @@ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { z } from 'zod'
import { cancelUnreadResponseBody } from '../../lib/unread-response-body'
import { readFetchResponseJsonWithinLimit } from '../../../shared/fetch-response-body'
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file'
import { fetchRelayRegionCatalog, relayDirectorHost } from './relay-region-catalog-fetch'
import {
logRelayRegionEvent,
relayRegionCacheHitEvent,
relayRegionCatalogFailureEvent,
relayRegionOverrideEvent,
relayRegionRefreshEvent,
RELAY_REGION_SELF_HEAL_EVENT,
type RelayRegionLogSink,
type RelayRegionSelfHealLogEvent
} from './relay-region-probe-log'
import {
measureOriginLatency,
RELAY_REGIONS,
measureRegion,
probeRelayOrigin,
PROBE_TIMEOUT_MS,
RelayRegionCatalogSchema,
regionMeasurement,
RelayRegionSchema,
type RegionMeasurement,
type RelayProbe,
type RelayRegion,
type RelayRegionCatalog
type RelayRegionCatalog,
type RelayRegionProbeReport
} from './relay-region-probe'
export { RELAY_REGIONS, type RelayRegion } from './relay-region-probe'
const RELAY_REGION_CACHE_FILENAME = 'orca-relay-region-preference.json'
const CACHE_MAX_BYTES = 8 * 1024
const CATALOG_MAX_BYTES = 16 * 1024
const CACHE_TTL_MS = 24 * 60 * 60_000
// A withheld hint is cheap to revisit but expensive to re-measure on every
// reconnect, so it is remembered for far less time than a chosen region.
@@ -54,6 +63,7 @@ type RelayRegionPreferenceOptions = {
diagnosticOverride?: string
probe?: RelayProbe
requestTimeoutMs?: number
logEvent?: RelayRegionLogSink
}
export class RelayRegionPreferenceResolver {
@@ -68,12 +78,22 @@ export class RelayRegionPreferenceResolver {
async resolve(): Promise<RelayRegion | undefined> {
const override = this.overrideRegion()
if (override) {
this.log(
relayRegionOverrideEvent({ directorUrl: this.options.directorUrl, region: override })
)
return override
}
const now = (this.options.now ?? Date.now)()
const cache = readRelayRegionCache(this.cachePath(), this.options.directorUrl, now)
if (cache && cache.expiresAt > now) {
this.log(
relayRegionCacheHitEvent({
directorUrl: this.options.directorUrl,
region: cache.region,
ttlMs: cache.expiresAt - now
})
)
return cache.region ?? undefined
}
if (this.pending) {
@@ -101,47 +121,109 @@ export class RelayRegionPreferenceResolver {
return
}
this.selfHealedCells.add(assignedCellOrigin)
const outcome: Omit<RelayRegionSelfHealLogEvent, 'directorHost'> = {
event: RELAY_REGION_SELF_HEAL_EVENT,
cachedRegion: cache.region,
bestRegion: null,
bestLatencyMs: null,
assignedCellUrl: assignedCellOrigin,
assignedLatencyMs: null,
decision: 'kept',
reason: 'catalog-unavailable'
}
try {
const fetch = this.options.fetch ?? globalThis.fetch
const catalog = await this.fetchCatalog(fetch)
const probe = this.createProbe(fetch)
const best = bestMeasurement(await measureCatalogRegions(catalog, probe))
// A director that cannot list its regions is the one self-heal outcome a
// support log would otherwise never see, so it is reported before the throw.
const reports = await this.probeCatalog(fetch, () => this.logSelfHeal(outcome))
const best = bestMeasurement(measuredRegions(reports))
outcome.bestRegion = best?.region ?? null
outcome.bestLatencyMs = best?.latencyMs ?? null
outcome.reason = best ? 'best-matches-cache' : 'no-region-measured'
// A far cell under a cache that still names the best region is the
// director declining the hint; deleting it would only re-probe.
if (!best || best.region === cache.region) {
this.logSelfHeal(outcome)
return
}
const assignedMs = await measureOriginLatency(assignedCellOrigin, probe)
if (assignedMs !== null && assignedMs > best.latencyMs * FAR_CELL_RATIO) {
outcome.assignedLatencyMs = assignedMs
const far = assignedMs !== null && assignedMs > best.latencyMs * FAR_CELL_RATIO
outcome.decision = far ? 'deleted' : 'kept'
outcome.reason = far ? 'assigned-cell-far' : 'assigned-cell-near'
if (far) {
rmSync(this.cachePath(), { force: true })
}
this.logSelfHeal(outcome)
} catch {
// Self-heal is best effort; a failed probe must never disturb the session.
}
}
private logSelfHeal(outcome: Omit<RelayRegionSelfHealLogEvent, 'directorHost'>): void {
this.log({ ...outcome, directorHost: relayDirectorHost(this.options.directorUrl) })
}
private async refresh(
previous: RelayRegionCache | null,
now: number
): Promise<RelayRegion | undefined> {
const fetch = this.options.fetch ?? globalThis.fetch
const catalog = await this.fetchCatalog(fetch)
const measurements = await measureCatalogRegions(catalog, this.createProbe(fetch))
// Only a refresh withholds a hint, so only a refresh reports the catalog
// failure as a probe event; self-heal reports it as its own outcome.
const reports = await this.probeCatalog(fetch, () =>
this.log(relayRegionCatalogFailureEvent(this.options.directorUrl))
)
const measurements = measuredRegions(reports)
// Why: a region may only win against a measured competitor. With a rejected
// or unmeasurable peer, director default placement beats a lone survivor.
const selected =
measurements.length < catalog.regions.length
measurements.length < reports.length
? null
: selectRegionMeasurement(measurements, previous?.region ?? null)
const ttlMs = selected ? CACHE_TTL_MS : NO_HINT_TTL_MS
this.log(
relayRegionRefreshEvent({
directorUrl: this.options.directorUrl,
reports,
best: bestMeasurement(measurements),
selected,
ttlMs
})
)
this.writeCache(
selected
? { region: selected.region, latencyMs: selected.latencyMs, ttlMs: CACHE_TTL_MS }
: { region: null, ttlMs: NO_HINT_TTL_MS },
? { region: selected.region, latencyMs: selected.latencyMs, ttlMs }
: { region: null, ttlMs },
now
)
return selected?.region
}
private async probeCatalog(
fetch: typeof globalThis.fetch,
onCatalogFailure?: () => void
): Promise<RelayRegionProbeReport[]> {
let catalog: RelayRegionCatalog
try {
catalog = await fetchRelayRegionCatalog(
this.options.directorUrl,
fetch,
this.options.requestTimeoutMs ?? PROBE_TIMEOUT_MS
)
} catch (error) {
onCatalogFailure?.()
throw error
}
const probe = this.createProbe(fetch)
return await Promise.all(catalog.regions.map((entry) => measureRegion(entry, probe)))
}
private log(event: Parameters<RelayRegionLogSink>[0]): void {
;(this.options.logEvent ?? logRelayRegionEvent)(event)
}
private writeCache(
entry: { region: RelayRegion | null; latencyMs?: number; ttlMs: number },
now: number
@@ -182,14 +264,6 @@ export class RelayRegionPreferenceResolver {
))
)
}
private async fetchCatalog(fetch: typeof globalThis.fetch): Promise<RelayRegionCatalog> {
return await fetchRelayRegionCatalog(
this.options.directorUrl,
fetch,
this.options.requestTimeoutMs ?? PROBE_TIMEOUT_MS
)
}
}
export function createRelayRegionPreferenceReader(input: {
@@ -209,45 +283,10 @@ export function createRelayRegionPreferenceReader(input: {
}
}
async function measureCatalogRegions(
catalog: RelayRegionCatalog,
probe: RelayProbe
): Promise<RegionMeasurement[]> {
const measured = await Promise.all(catalog.regions.map((entry) => measureRegion(entry, probe)))
return measured.filter((measurement): measurement is RegionMeasurement => measurement !== null)
}
async function fetchRelayRegionCatalog(
directorUrl: string,
fetch: typeof globalThis.fetch,
timeoutMs: number
): Promise<RelayRegionCatalog> {
if (!isCanonicalDirectorOrigin(directorUrl)) {
throw new Error('invalid relay director origin')
}
const response = await fetch(`${directorUrl}/v1/regions`, {
method: 'GET',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
throw new Error(`relay region catalog failed (${response.status})`)
}
const body = await readFetchResponseJsonWithinLimit<unknown>(response, CATALOG_MAX_BYTES, {
structuralTokens: 64,
nestingDepth: 8
})
const catalog = RelayRegionCatalogSchema.parse(body)
if (
catalog.regions.some((entry) =>
entry.probeOrigins.some((origin) => !isProbeOriginForDirector(origin, directorUrl))
)
) {
throw new Error('relay probe origin does not belong to the director')
}
return catalog
function measuredRegions(reports: RelayRegionProbeReport[]): RegionMeasurement[] {
return reports
.map(regionMeasurement)
.filter((measurement): measurement is RegionMeasurement => measurement !== null)
}
function bestMeasurement(measurements: RegionMeasurement[]): RegionMeasurement | null {
@@ -297,19 +336,3 @@ function readRelayRegionCache(path: string, directorUrl: string, now: number) {
return null
}
}
function isCanonicalDirectorOrigin(value: string): boolean {
try {
const url = new URL(value)
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
return (
url.origin === value && (url.protocol === 'https:' || (url.protocol === 'http:' && loopback))
)
} catch {
return false
}
}
function isProbeOriginForDirector(origin: string, directorUrl: string): boolean {
return new URL(origin).hostname.endsWith(`.${new URL(directorUrl).hostname}`)
}
@@ -0,0 +1,360 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RelayRegionPreferenceResolver } from './relay-region-preference'
import {
logRelayRegionEvent,
RELAY_REGION_PROBE_EVENT,
RELAY_REGION_SELF_HEAL_EVENT,
type RelayRegionLogEvent,
type RelayRegionProbeLogEvent,
type RelayRegionSelfHealLogEvent
} from './relay-region-probe-log'
const DIRECTOR = 'https://relay.example.test'
const US = 'https://us-c1.relay.example.test'
const ASIA = 'https://asia-c1.relay.example.test'
const CELL = 'https://cell-7.relay.example.test'
const BOTH_REGIONS = [
{ region: 'us-central1', probeOrigins: [US] },
{ region: 'asia-east2', probeOrigins: [ASIA] }
]
const tempPaths: string[] = []
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
for (const path of tempPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
function userDataPath(): string {
const path = mkdtempSync(join(tmpdir(), 'orca-relay-region-log-'))
tempPaths.push(path)
return path
}
function catalogFetch(regions: unknown) {
return vi.fn<typeof globalThis.fetch>(async () => Response.json({ v: 1, regions }))
}
// Each list starts with the discarded warm-up probe, then the three kept samples.
function sampledProbe(samples: Record<string, number[]>) {
return async (origin: string): Promise<number | null> => samples[origin]?.shift() ?? null
}
function writeCache(path: string, region: string | null, expiresAt: number): void {
writeFileSync(
join(path, 'orca-relay-region-preference.json'),
JSON.stringify({ v: 1, directorUrl: DIRECTOR, region, expiresAt })
)
}
function resolverWithLog(options: {
path: string
fetch: typeof globalThis.fetch
probe?: (origin: string) => Promise<number | null>
now?: () => number
}) {
const events: RelayRegionLogEvent[] = []
const resolver = new RelayRegionPreferenceResolver({
directorUrl: DIRECTOR,
userDataPath: options.path,
fetch: options.fetch,
probe: options.probe,
now: options.now ?? (() => 1_000),
logEvent: (event) => events.push(event)
})
return { resolver, events }
}
function probeEvents(events: RelayRegionLogEvent[]): RelayRegionProbeLogEvent[] {
return events.filter(
(event): event is RelayRegionProbeLogEvent => event.event === RELAY_REGION_PROBE_EVENT
)
}
describe('Relay region probe log', () => {
it('records every probed origin, the discarded warm-up, and the kept samples', async () => {
const path = userDataPath()
const { resolver, events } = resolverWithLog({
path,
fetch: catalogFetch(BOTH_REGIONS),
probe: sampledProbe({ [US]: [400, 160, 170, 150], [ASIA]: [90, 35, 40, 30] })
})
await expect(resolver.resolve()).resolves.toBe('asia-east2')
const [event] = probeEvents(events)
expect(event).toMatchObject({
event: 'relay_region_probe',
directorHost: 'relay.example.test',
chosenRegion: 'asia-east2',
reason: 'measured',
cached: false,
ttlMs: 24 * 60 * 60_000
})
expect(JSON.stringify(event)).not.toMatch(/token|jwt|secret|authorization|bearer|eyJ/i)
expect(event.regions).toEqual([
{
region: 'us-central1',
origins: [US],
warmupMs: [400],
keptMs: [150, 160, 170],
minMs: 150,
spreadMs: 20,
verdict: 'measured'
},
{
region: 'asia-east2',
origins: [ASIA],
warmupMs: [90],
keptMs: [30, 35, 40],
minMs: 30,
spreadMs: 10,
verdict: 'measured'
}
])
})
it('reports a flapping region as rejected-spread and withholds the hint', async () => {
const path = userDataPath()
const { resolver, events } = resolverWithLog({
path,
fetch: catalogFetch(BOTH_REGIONS),
probe: sampledProbe({ [US]: [400, 160, 170, 150], [ASIA]: [90, 30, 40, 900] })
})
await expect(resolver.resolve()).resolves.toBeUndefined()
const [event] = probeEvents(events)
expect(event.regions.map((region) => region.verdict)).toEqual(['measured', 'rejected-spread'])
expect(event).toMatchObject({
chosenRegion: 'no-hint',
reason: 'sole-survivor-forbidden',
ttlMs: 60 * 60_000
})
})
it('separates an unreachable region from a rejected one', async () => {
const path = userDataPath()
const { resolver, events } = resolverWithLog({
path,
fetch: catalogFetch(BOTH_REGIONS),
probe: sampledProbe({})
})
await expect(resolver.resolve()).resolves.toBeUndefined()
const [event] = probeEvents(events)
expect(event.reason).toBe('all-unreachable')
expect(event.regions).toEqual([
{
region: 'us-central1',
origins: [US],
warmupMs: [null],
keptMs: [],
minMs: null,
spreadMs: null,
verdict: 'unreachable'
},
{
region: 'asia-east2',
origins: [ASIA],
warmupMs: [null],
keptMs: [],
minMs: null,
spreadMs: null,
verdict: 'unreachable'
}
])
})
it('names a held incumbent apart from a fresh measurement', async () => {
const path = userDataPath()
writeCache(path, 'us-central1', 500)
const { resolver, events } = resolverWithLog({
path,
fetch: catalogFetch(BOTH_REGIONS),
probe: sampledProbe({ [US]: [400, 100, 100, 100], [ASIA]: [90, 90, 90, 90] })
})
await expect(resolver.resolve()).resolves.toBe('us-central1')
expect(probeEvents(events)[0]).toMatchObject({
chosenRegion: 'us-central1',
reason: 'held-previous'
})
})
it('logs a cache hit with the remaining TTL and no probe rounds', async () => {
const path = userDataPath()
writeCache(path, 'asia-east2', 5_000)
const fetch = catalogFetch(BOTH_REGIONS)
const { resolver, events } = resolverWithLog({ path, fetch })
await expect(resolver.resolve()).resolves.toBe('asia-east2')
expect(fetch).not.toHaveBeenCalled()
expect(events).toEqual([
{
event: 'relay_region_probe',
directorHost: 'relay.example.test',
regions: [],
chosenRegion: 'asia-east2',
reason: 'cached',
cached: true,
ttlMs: 4_000
}
])
})
it('logs a cached no-hint as no-hint rather than an absent region', async () => {
const path = userDataPath()
writeCache(path, null, 5_000)
const { resolver, events } = resolverWithLog({ path, fetch: catalogFetch(BOTH_REGIONS) })
await expect(resolver.resolve()).resolves.toBeUndefined()
expect(probeEvents(events)[0]).toMatchObject({ chosenRegion: 'no-hint', reason: 'cached' })
})
it('logs a diagnostic override without probing', async () => {
const path = userDataPath()
const events: RelayRegionLogEvent[] = []
const resolver = new RelayRegionPreferenceResolver({
directorUrl: DIRECTOR,
userDataPath: path,
fetch: catalogFetch(BOTH_REGIONS),
diagnosticOverride: 'asia-east2',
logEvent: (event) => events.push(event)
})
await expect(resolver.resolve()).resolves.toBe('asia-east2')
expect(probeEvents(events)[0]).toMatchObject({
chosenRegion: 'asia-east2',
reason: 'override',
cached: false
})
})
it('logs a director that cannot list its regions instead of going silent', async () => {
const path = userDataPath()
const { resolver, events } = resolverWithLog({
path,
fetch: vi.fn<typeof globalThis.fetch>(async () => new Response('nope', { status: 503 }))
})
await expect(resolver.resolve()).resolves.toBeUndefined()
expect(probeEvents(events)[0]).toMatchObject({
chosenRegion: 'no-hint',
reason: 'catalog-unavailable',
regions: []
})
})
it('logs the self-heal decision that deletes a cache pinning a far cell', async () => {
const path = userDataPath()
writeCache(path, 'us-central1', 5_000)
const { resolver, events } = resolverWithLog({
path,
fetch: catalogFetch(BOTH_REGIONS),
probe: sampledProbe({
[US]: [400, 300, 300, 300],
[ASIA]: [90, 30, 30, 30],
[CELL]: [400, 300, 300, 300]
})
})
await resolver.invalidateIfAssignedCellIsFar(CELL)
const selfHeal = events.find(
(event): event is RelayRegionSelfHealLogEvent => event.event === RELAY_REGION_SELF_HEAL_EVENT
)
expect(selfHeal).toEqual({
event: 'relay_region_self_heal',
directorHost: 'relay.example.test',
cachedRegion: 'us-central1',
bestRegion: 'asia-east2',
bestLatencyMs: 30,
assignedCellUrl: CELL,
assignedLatencyMs: 300,
decision: 'deleted',
reason: 'assigned-cell-far'
})
})
it('logs a kept cache when the assigned cell is not far from the best region', async () => {
const path = userDataPath()
writeCache(path, 'us-central1', 5_000)
const { resolver, events } = resolverWithLog({
path,
fetch: catalogFetch(BOTH_REGIONS),
probe: sampledProbe({
[US]: [400, 300, 300, 300],
[ASIA]: [90, 200, 200, 200],
[CELL]: [400, 300, 300, 300]
})
})
await resolver.invalidateIfAssignedCellIsFar(CELL)
expect(events.at(-1)).toMatchObject({
event: 'relay_region_self_heal',
decision: 'kept',
reason: 'assigned-cell-near',
assignedLatencyMs: 300
})
})
it('reports a self-heal whose catalog failed as its own outcome, not a withheld hint', async () => {
const path = userDataPath()
writeCache(path, 'us-central1', 5_000)
const { resolver, events } = resolverWithLog({
path,
fetch: vi.fn<typeof globalThis.fetch>(async () => new Response('nope', { status: 503 }))
})
await resolver.invalidateIfAssignedCellIsFar(CELL)
// A self-heal that never chose a region must not log a probe event that
// reads as a withheld hint; it names the failure under its own event.
expect(events.map((event) => event.event)).toEqual([RELAY_REGION_SELF_HEAL_EVENT])
expect(events[0]).toMatchObject({ decision: 'kept', reason: 'catalog-unavailable' })
})
it('emits one credential-free line per event', () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
logRelayRegionEvent({
event: RELAY_REGION_PROBE_EVENT,
directorHost: 'relay.example.test',
regions: [
{
region: 'asia-east2',
origins: [ASIA],
warmupMs: [90],
keptMs: [30, 35, 40],
minMs: 30,
spreadMs: 10,
verdict: 'measured'
}
],
chosenRegion: 'asia-east2',
reason: 'measured',
cached: false,
ttlMs: 1_000
})
expect(info).toHaveBeenCalledTimes(1)
const [tag, line] = info.mock.calls[0] as [string, string]
expect(tag).toBe('[relay-region]')
expect(line).not.toContain('\n')
expect(JSON.parse(line)).toMatchObject({ event: 'relay_region_probe' })
expect(line).not.toMatch(/token|jwt|secret|authorization|bearer|relayHostId|eyJ/i)
})
})
@@ -0,0 +1,133 @@
import { relayDirectorHost } from './relay-region-catalog-fetch'
import type { RegionMeasurement, RelayRegion, RelayRegionProbeReport } from './relay-region-probe'
export const RELAY_REGION_PROBE_EVENT = 'relay_region_probe'
export const RELAY_REGION_SELF_HEAL_EVENT = 'relay_region_self_heal'
/** Why the resolver ended up with the region it returned, or with no hint. */
export type RelayRegionChoiceReason =
| 'measured'
| 'held-previous'
| 'sole-survivor-forbidden'
| 'all-unreachable'
| 'all-rejected'
| 'catalog-unavailable'
| 'override'
| 'cached'
export type RelayRegionProbeLogEvent = {
event: typeof RELAY_REGION_PROBE_EVENT
directorHost: string
regions: RelayRegionProbeReport[]
chosenRegion: RelayRegion | 'no-hint'
reason: RelayRegionChoiceReason
cached: boolean
ttlMs: number
}
export type RelayRegionSelfHealDecision = 'kept' | 'deleted'
export type RelayRegionSelfHealLogEvent = {
event: typeof RELAY_REGION_SELF_HEAL_EVENT
directorHost: string
cachedRegion: RelayRegion
bestRegion: RelayRegion | null
bestLatencyMs: number | null
assignedCellUrl: string
assignedLatencyMs: number | null
decision: RelayRegionSelfHealDecision
reason:
| 'best-matches-cache'
| 'no-region-measured'
| 'catalog-unavailable'
| 'assigned-cell-near'
| 'assigned-cell-far'
}
export type RelayRegionLogEvent = RelayRegionProbeLogEvent | RelayRegionSelfHealLogEvent
export type RelayRegionLogSink = (event: RelayRegionLogEvent) => void
// JSON rather than an object argument: Node pretty-prints nested objects across
// many lines, and a support log census needs one grep-able line per event.
export function logRelayRegionEvent(event: RelayRegionLogEvent): void {
console.info('[relay-region]', JSON.stringify(event))
}
export function relayRegionCacheHitEvent(input: {
directorUrl: string
region: RelayRegion | null
ttlMs: number
}): RelayRegionProbeLogEvent {
return {
event: RELAY_REGION_PROBE_EVENT,
directorHost: relayDirectorHost(input.directorUrl),
regions: [],
chosenRegion: input.region ?? 'no-hint',
reason: 'cached',
cached: true,
ttlMs: input.ttlMs
}
}
export function relayRegionOverrideEvent(input: {
directorUrl: string
region: RelayRegion
}): RelayRegionProbeLogEvent {
return {
event: RELAY_REGION_PROBE_EVENT,
directorHost: relayDirectorHost(input.directorUrl),
regions: [],
chosenRegion: input.region,
reason: 'override',
cached: false,
ttlMs: 0
}
}
export function relayRegionCatalogFailureEvent(directorUrl: string): RelayRegionProbeLogEvent {
return {
event: RELAY_REGION_PROBE_EVENT,
directorHost: relayDirectorHost(directorUrl),
regions: [],
chosenRegion: 'no-hint',
reason: 'catalog-unavailable',
cached: false,
ttlMs: 0
}
}
export function relayRegionRefreshEvent(input: {
directorUrl: string
reports: RelayRegionProbeReport[]
best: RegionMeasurement | null
selected: RegionMeasurement | null
ttlMs: number
}): RelayRegionProbeLogEvent {
return {
event: RELAY_REGION_PROBE_EVENT,
directorHost: relayDirectorHost(input.directorUrl),
regions: input.reports,
chosenRegion: input.selected?.region ?? 'no-hint',
reason: refreshReason(input.reports, input.best, input.selected),
cached: false,
ttlMs: input.ttlMs
}
}
function refreshReason(
reports: RelayRegionProbeReport[],
best: RegionMeasurement | null,
selected: RegionMeasurement | null
): RelayRegionChoiceReason {
if (selected) {
// The resolver keeps the incumbent unless a rival wins by a real margin, so
// a selection that is not the fastest reading is a deliberate hold.
return best && selected.region !== best.region ? 'held-previous' : 'measured'
}
if (reports.some((report) => report.verdict === 'measured')) {
return 'sole-survivor-forbidden'
}
return reports.every((report) => report.verdict === 'unreachable')
? 'all-unreachable'
: 'all-rejected'
}
+44 -18
View File
@@ -83,51 +83,77 @@ export async function probeRelayOrigin(
}
}
type LatencySamples = {
/** Discarded first round per origin; it pays TCP and TLS setup. */
warmupMs: (number | null)[]
/** Ascending per-round minimums across the live origins; empty means unreachable. */
keptMs: number[]
}
export type RelayRegionProbeVerdict = 'measured' | 'rejected-spread' | 'unreachable'
export type RelayRegionProbeReport = {
region: RelayRegion
origins: string[]
warmupMs: (number | null)[]
keptMs: number[]
minMs: number | null
spreadMs: number | null
verdict: RelayRegionProbeVerdict
}
// The first request of a process pays TCP and TLS setup, which can exceed the
// round trip it is meant to measure, so it is discarded before sampling.
async function sampleMinLatencies(origins: string[], probe: RelayProbe): Promise<number[] | null> {
const warmup = await Promise.all(origins.map(probe))
async function sampleMinLatencies(origins: string[], probe: RelayProbe): Promise<LatencySamples> {
const warmupMs = await Promise.all(origins.map(probe))
// An origin that failed its warm-up would spend one probe timeout per round
// to report nothing, so the sampling rounds skip it entirely.
const live = origins.filter((_origin, index) => warmup[index] !== null)
const live = origins.filter((_origin, index) => warmupMs[index] !== null)
if (live.length === 0) {
return null
return { warmupMs, keptMs: [] }
}
const samples: number[] = []
const keptMs: number[] = []
for (let sample = 0; sample < PROBE_SAMPLES; sample++) {
const latencies = (await Promise.all(live.map(probe))).filter(
(latency): latency is number => latency !== null
)
if (latencies.length === 0) {
return null
return { warmupMs, keptMs: [] }
}
samples.push(Math.min(...latencies))
keptMs.push(Math.min(...latencies))
}
return samples.sort((left, right) => left - right)
keptMs.sort((left, right) => left - right)
return { warmupMs, keptMs }
}
export async function measureOriginLatency(
origin: string,
probe: RelayProbe
): Promise<number | null> {
return (await sampleMinLatencies([origin], probe))?.[0] ?? null
return (await sampleMinLatencies([origin], probe)).keptMs[0] ?? null
}
export async function measureRegion(
entry: RelayRegionCatalogEntry,
probe: RelayProbe
): Promise<RegionMeasurement | null> {
const samples = await sampleMinLatencies(entry.probeOrigins, probe)
if (!samples) {
return null
): Promise<RelayRegionProbeReport> {
const { warmupMs, keptMs } = await sampleMinLatencies(entry.probeOrigins, probe)
const probed = { region: entry.region, origins: entry.probeOrigins, warmupMs, keptMs }
if (keptMs.length === 0) {
return { ...probed, minMs: null, spreadMs: null, verdict: 'unreachable' }
}
const [min, median, max] = samples as [number, number, number]
const [min, median, max] = keptMs as [number, number, number]
const spreadMs = max - min
// Regions compare by their best round trip; the spread check only rejects a
// path that is genuinely flapping, not one that warmed up.
if (max - min > Math.max(SPREAD_FLOOR_MS, median)) {
return null
}
return { region: entry.region, latencyMs: min }
const verdict = spreadMs > Math.max(SPREAD_FLOOR_MS, median) ? 'rejected-spread' : 'measured'
return { ...probed, minMs: min, spreadMs, verdict }
}
export function regionMeasurement(report: RelayRegionProbeReport): RegionMeasurement | null {
return report.verdict === 'measured' && report.minMs !== null
? { region: report.region, latencyMs: report.minMs }
: null
}
function isCanonicalHttpsOrigin(value: string): boolean {
@@ -24,7 +24,8 @@ export type RelaySessionBrokerOptions = {
refreshAccessToken: () => Promise<string | null>
resolvePreferredRegion?: () => Promise<RelayRegion | undefined>
onAssignedCellActive?: (cellUrl: string) => void
onStatus: (status: RelayBrokerStatus) => void
/** `cellUrl` is absent whenever the host holds no active assignment. */
onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void
fetch?: typeof globalThis.fetch
createControlSocket?: (url: string, relayJwt: string) => WebSocket
createDataSocket?: (url: string) => WebSocket
@@ -79,6 +79,7 @@ vi.mock('../rpc/relay-transport', () => ({
stop = vi.fn().mockResolvedValue(undefined)
setGeneration = vi.fn()
metadataFor = vi.fn()
hasConnection = vi.fn(() => false)
openConnection = vi.fn().mockResolvedValue(undefined)
constructor() {
@@ -111,6 +112,33 @@ describe('RelaySessionBroker lifecycle ownership', () => {
})
})
it('publishes the assigned cell with the status and drops it on close', async () => {
fakes.controlConnect.mockResolvedValue({
type: 'host-hello-ack',
v: 1,
generation: 1,
controlResumeSecret: 'A'.repeat(43),
leaseExpiresAt: 1_000_000,
activeConnIds: [],
pendingConns: []
} satisfies RelayHostHelloAckMessage)
const onStatus = vi.fn()
const broker = await RelaySessionBroker.connect(brokerOptions({ onStatus }))
expect(onStatus.mock.calls).toContainEqual(['connecting', undefined])
expect(onStatus).toHaveBeenLastCalledWith('registered', 'https://relay.example.test')
// Why: the pool publishes offline while it still holds the assignment it is
// about to rotate; forwarding that cell leaves the UI naming a dead one.
fakes.controls[0]!.options.onClose(1006)
expect(onStatus.mock.calls).toContainEqual(['offline', undefined])
expect(onStatus.mock.calls).toContainEqual(['draining', 'https://relay.example.test'])
broker.closeNow()
expect(onStatus).toHaveBeenLastCalledWith('offline')
})
it('closes partially opened resources without publishing stale state', async () => {
const controlAck = deferred<RelayHostHelloAckMessage>()
fakes.controlConnect.mockReturnValue(controlAck.promise)
@@ -346,6 +374,60 @@ describe('RelaySessionBroker lifecycle ownership', () => {
expect(onAssignedCellActive).toHaveBeenLastCalledWith('https://cell-b.relay.example.test')
})
it('attaches a phone whose accept straddles a control rebind', async () => {
const ack: RelayHostHelloAckMessage = {
type: 'host-hello-ack',
v: 1,
generation: 7,
controlResumeSecret: 'R'.repeat(43),
leaseExpiresAt: 1_000_000,
activeConnIds: [],
pendingConns: []
}
fakes.controlConnect.mockResolvedValueOnce(ack).mockResolvedValueOnce({
...ack,
leaseExpiresAt: 2_000_000,
// The cell restates the connection it already announced once; without the
// replay the phone waits out its 10s attach deadline and is closed 4404.
pendingConns: [{ connId: 'straddling-basis', connTicket: 'T'.repeat(43) }]
})
fakes.assign.mockResolvedValue({
cellUrl: 'https://relay.example.test',
assignmentEpoch: 1,
leaseExpiresAt: 2_000_000
})
const broker = await RelaySessionBroker.connect(brokerOptions())
fakes.controls[0]!.options.onConnectionOpen({
connId: 'straddling-basis',
connTicket: 'T'.repeat(43),
kind: 'invite',
relayDeviceId: 'device-1',
attachDeadlineMs: 10_000
})
// The blip that costs the control also kills the in-flight data socket.
fakes.transports[0]!.openConnection.mockClear()
fakes.controls[0]!.options.onDrain({
type: 'drain',
graceMs: 5_000,
recovery: 'resolve-director'
})
await vi.waitFor(() => expect(fakes.controls).toHaveLength(2))
expect(fakes.transports).toHaveLength(1)
await vi.waitFor(() =>
expect(fakes.transports[0]!.openConnection).toHaveBeenCalledWith({
type: 'conn-open',
connId: 'straddling-basis',
connTicket: 'T'.repeat(43),
kind: 'invite',
relayDeviceId: 'device-1',
attachDeadlineMs: 10_000
})
)
expect(brokerBasisIds(broker)).toEqual(['straddling-basis'])
})
it('opens a fresh same-cell generation when process-local rebind state is lost', async () => {
const ack: RelayHostHelloAckMessage = {
type: 'host-hello-ack',
@@ -387,7 +469,9 @@ describe('RelaySessionBroker lifecycle ownership', () => {
expect(fakes.controls[2]!.options.previousGeneration).toBeUndefined()
expect(fakes.controls[2]!.options.controlResumeSecret).toBeUndefined()
expect(fakes.transports).toHaveLength(2)
await vi.waitFor(() => expect(onStatus).toHaveBeenLastCalledWith('registered'))
await vi.waitFor(() =>
expect(onStatus).toHaveBeenLastCalledWith('registered', 'https://relay.example.test')
)
expect(broker.endpoint?.cellUrl).toBe('https://relay.example.test')
})
@@ -1,3 +1,4 @@
import { relayStatusCellUrl } from '../../../shared/mobile-relay-status'
import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer'
import type {
DeviceCredentialInstalled,
@@ -296,8 +297,8 @@ export class RelaySessionBroker {
if (!this.isCurrent()) {
return
}
this.options.onStatus(status)
const cellUrl = this.originPool.activeAssignment?.cellUrl
this.options.onStatus(status, relayStatusCellUrl(status, cellUrl))
if (status === 'registered' && cellUrl) {
// Fire-and-forget: the listener may probe this cell, and nothing about the
// live session is allowed to wait on that.
@@ -49,6 +49,7 @@ describe('client UI RPC pairing-local field seams', () => {
agentsFilterRepoIds: ['repo-a'],
agentsShowChildAgents: true,
agentsCompactMode: false,
agentsShowSearch: false,
agentsReadFilter: 'unread',
agentsGroupBy: 'project',
activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 },
@@ -130,6 +130,7 @@ const UiUpdateFields = z
agentsFilterRepoIds: StringArray.optional(),
agentsShowChildAgents: z.boolean().optional(),
agentsCompactMode: z.boolean().optional(),
agentsShowSearch: z.boolean().optional(),
agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(),
agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(),
workspaceHostOrder: z.array(z.string()).optional(),
+4
View File
@@ -104,6 +104,10 @@ export class CloudRelayTransport implements RpcTransport, MobileSocketTransport
this.generation = generation
}
hasConnection(connectionId: string): boolean {
return this.socketsByConnectionId.has(connectionId)
}
terminateClientConnections(clientId: string): number {
const sockets = Array.from(this.clientIds.entries())
.filter(([, candidate]) => candidate === clientId)
@@ -13,6 +13,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend'
import { browserManager } from '../browser/browser-manager'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve'
import {
@@ -91,7 +92,10 @@ function installRuntimeRpc(
})
state.runtimeRpc = runtimeRpc
registerMobileHandlers(runtimeRpc, {
getRelayStatus: () => state.desktopRelayStatus,
getRelayStatus: () => ({
status: state.desktopRelayStatus,
...(state.desktopRelayCellUrl === undefined ? {} : { cellUrl: state.desktopRelayCellUrl })
}),
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
if (
!state.mainWindow ||
@@ -249,9 +253,13 @@ async function launchDesktopMode(
userDataPath: getProfileUserDataPath(),
appVersion: app.getVersion(),
runtimeRpc,
onStatus: (status) => {
onStatus: (status, cellUrl) => {
state.desktopRelayStatus = status
state.mainWindow?.webContents.send('mobile:relayStatusChanged', status)
state.desktopRelayCellUrl = cellUrl
state.mainWindow?.webContents.send('mobile:relayStatusChanged', {
status,
...(cellUrl === undefined ? {} : { cellUrl })
} satisfies MobileRelayStatusDetail)
}
})
state.desktopRelayService = relayService
+1
View File
@@ -66,6 +66,7 @@ export const mainProcessState = {
serveReadinessPublisher: new ServeReadinessPublisher(),
desktopRelayService: null as DesktopRelayService | null,
desktopRelayStatus: 'offline' as RelayBrokerStatus,
desktopRelayCellUrl: undefined as string | undefined,
pendingUnpairedDeviceAuthFailure: false,
// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support).
headlessBrowserDisplayAvailable: false,
+3 -3
View File
@@ -1,4 +1,4 @@
import type { MobileRelayStatus } from '../../shared/mobile-relay-status'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure'
@@ -79,8 +79,8 @@ export type MobileApi = {
listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }>
revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }>
isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }>
getRelayStatus: () => Promise<{ status: MobileRelayStatus }>
onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void
getRelayStatus: () => Promise<MobileRelayStatusDetail>
onRelayStatusChanged: (callback: (detail: MobileRelayStatusDetail) => void) => () => void
/** Consumes an auth-failure notification that arrived before the renderer listener mounted. */
consumePendingUnpairedDeviceAuthFailure?: () => Promise<boolean>
/** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */
+5 -5
View File
@@ -1,5 +1,5 @@
import { ipcRenderer } from 'electron'
import type { MobileRelayStatus } from '../../shared/mobile-relay-status'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure'
@@ -74,12 +74,12 @@ export const mobileApi = {
isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> =>
ipcRenderer.invoke('mobile:isWebSocketReady'),
getRelayStatus: (): Promise<{ status: MobileRelayStatus }> =>
getRelayStatus: (): Promise<MobileRelayStatusDetail> =>
ipcRenderer.invoke('mobile:getRelayStatus'),
onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) =>
callback(status)
onRelayStatusChanged: (callback: (detail: MobileRelayStatusDetail) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, detail: MobileRelayStatusDetail) =>
callback(detail)
ipcRenderer.on('mobile:relayStatusChanged', listener)
return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener)
},
@@ -189,8 +189,8 @@ describe('ActivityThreadOptionsMenu', () => {
)
})
it('puts search and unread actions in the menu when header overflow handlers are provided', async () => {
const onSearch = vi.fn()
it('puts persisted search visibility and unread actions in the menu', async () => {
const onShowSearchChange = vi.fn()
const onToggleUnread = vi.fn()
await act(async () => {
root.render(
@@ -200,7 +200,8 @@ describe('ActivityThreadOptionsMenu', () => {
hasUnreadThreads={false}
onCompactModeChange={vi.fn()}
onMarkAllThreadsRead={vi.fn()}
onSearch={onSearch}
showSearch
onShowSearchChange={onShowSearchChange}
unreadOnly={false}
onToggleUnread={onToggleUnread}
/>
@@ -215,8 +216,18 @@ describe('ActivityThreadOptionsMenu', () => {
trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
})
expect(document.body.textContent).toContain('Search')
expect(document.body.textContent).toContain('Show search')
expect(document.body.textContent).toContain('Show unread only')
const showSearchItem = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]')
).find((item) => item.textContent?.includes('Show search'))
expect(showSearchItem?.getAttribute('data-state')).toBe('checked')
await act(async () => {
showSearchItem?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
})
expect(onShowSearchChange).toHaveBeenCalledWith(false)
})
it('explains show unread threads only on hover without a second unread state marker', async () => {
@@ -77,7 +77,10 @@ export function ActivityThreadListToolbar({
'auto.components.activity.ActivityPrototypePage.795cbf26e2',
'Filter...'
)}
className={cn('h-7 w-full pl-6 text-[11px]', query ? 'pr-6' : '')}
className={cn(
'h-7 w-full pl-6 text-[11px] shadow-none focus-visible:ring-0',
query ? 'pr-6' : ''
)}
/>
{query ? (
<Button
@@ -60,7 +60,8 @@ export function ActivityThreadOptionsMenu({
onShowChildAgentsChange,
onMarkAllThreadsRead,
onClearCompleted,
onSearch,
showSearch = false,
onShowSearchChange,
unreadOnly = false,
onToggleUnread
}: {
@@ -74,7 +75,8 @@ export function ActivityThreadOptionsMenu({
onShowChildAgentsChange?: (showChildAgents: boolean) => void
onMarkAllThreadsRead?: () => void
onClearCompleted?: () => void
onSearch?: () => void
showSearch?: boolean
onShowSearchChange?: (showSearch: boolean) => void
unreadOnly?: boolean
onToggleUnread?: () => void
}): React.JSX.Element {
@@ -132,20 +134,26 @@ export function ActivityThreadOptionsMenu({
}
}}
>
{onSearch || onToggleUnread ? (
{onShowSearchChange || onToggleUnread ? (
<>
{onSearch ? (
<DropdownMenuItem
onSelect={() => {
skipCloseAutoFocusRef.current = true
onSearch()
{onShowSearchChange ? (
<DropdownMenuCheckboxItem
checked={showSearch}
className={ALIGNED_CHECKBOX_ITEM_CLASS}
onCheckedChange={(checked) => {
skipCloseAutoFocusRef.current = checked === true
onShowSearchChange(checked === true)
}}
>
<Search className="size-3.5 text-muted-foreground" />
<span>
{translate('auto.components.activity.ActivityPrototypePage.search', 'Search')}
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.activity.ActivityPrototypePage.showSearch',
'Show search'
)}
</span>
</DropdownMenuItem>
{showSearch ? <Check className="size-3.5" /> : null}
</DropdownMenuCheckboxItem>
) : null}
{onToggleUnread ? (
<Tooltip>
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { EventEmitter } from 'node:events'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { useRef } from 'react'
import type { NativeFileDropPayload } from '../../../../shared/native-file-drop'
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
import {
clearNativeChatAttachmentCacheForTests,
readNativeChatAttachmentCache,
useNativeChatComposerAttachments
} from './use-native-chat-composer-attachments'
const electron = vi.hoisted(() => ({
on: vi.fn(),
removeListener: vi.fn(),
send: vi.fn(),
getPathForFile: vi.fn((file: File) => `/repro/${file.name}`)
}))
vi.mock('electron', () => ({
ipcRenderer: electron,
webUtils: { getPathForFile: electron.getPathForFile }
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false }))
import {
installNativeFileDropHandlers,
subscribeNativeFileDrop
} from '../../../../preload/preload-runtime-support'
// Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache.
function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const attachments = useNativeChatComposerAttachments({
attachmentScopeKey: pane,
allowWithoutTarget: true,
caret: 0,
disabled: false,
isComposing: () => false,
resolveTarget: () => null,
textareaRef,
setCaret: () => {},
setDraft: () => {},
setNotice: () => {}
})
useNativeChatFileAttachmentActions(attachments.attachResolvedPaths)
return (
<div data-pane={pane} style={{ display: hidden ? 'none' : 'block' }}>
<textarea ref={textareaRef} data-native-file-drop-target="composer" />
<output>{JSON.stringify(attachments.imageAttachments.map(({ path }) => path))}</output>
</div>
)
}
function dropTwoImages(target: Element): void {
const event = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', {
value: {
types: ['Files'],
files: [new File(['a'], 'first.png'), new File(['b'], 'second.png')]
}
})
act(() => target.dispatchEvent(event))
}
describe('cross-pane image-drop reproduction (asserts the current bug)', () => {
beforeAll(() => {
const ipc = new EventEmitter()
electron.on.mockImplementation((channel, listener) => ipc.on(channel, listener))
electron.removeListener.mockImplementation((channel, listener) =>
ipc.removeListener(channel, listener)
)
// Mirror registerFileDropRelay: one window-wide notification per valid drop.
electron.send.mockImplementation((channel: string, payload: NativeFileDropPayload) => {
if (channel === 'terminal:file-dropped-from-preload') {
ipc.emit('terminal:file-drop', {}, payload)
}
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { onFileDrop: subscribeNativeFileDrop } }
})
installNativeFileDropHandlers()
})
afterEach(() => {
cleanup()
clearNativeChatAttachmentCacheForTests()
electron.send.mockClear()
})
it('adds both images to an untouched hidden pane and restores them on remount', () => {
const view = render(
<>
<ComposerProbe pane="chat-a" />
<ComposerProbe pane="chat-b" hidden />
</>
)
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
const target = view.container.querySelector('[data-pane="chat-a"] textarea')!
dropTwoImages(target)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
target: 'composer',
paths: ['/repro/first.png', '/repro/second.png']
})
for (const pane of ['chat-a', 'chat-b']) {
expect(readNativeChatAttachmentCache(pane).map(({ path }) => path)).toEqual([
'/repro/first.png',
'/repro/second.png'
])
}
view.unmount()
const returned = render(<ComposerProbe pane="chat-b" />)
expect(returned.container.querySelector('output')?.textContent).toBe(
'["/repro/first.png","/repro/second.png"]'
)
})
it('control: an editor-targeted drop does not attach images to either chat', () => {
const view = render(
<>
<ComposerProbe pane="chat-a" />
<ComposerProbe pane="chat-b" hidden />
<div data-native-file-drop-target="editor" />
</>
)
dropTwoImages(view.container.querySelector('[data-native-file-drop-target="editor"]')!)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
target: 'editor',
paths: ['/repro/first.png', '/repro/second.png']
})
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
})
})
@@ -6,7 +6,7 @@ import { StrictMode, useSyncExternalStore } from 'react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
import type { MobileRelayStatusDetail } from '../../../../shared/mobile-relay-status'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions'
@@ -45,7 +45,7 @@ vi.mock('../../i18n/i18n', () => ({
}))
describe('MobilePairingConnectionOptions', () => {
let statusListener: ((status: MobileRelayStatus) => void) | null
let statusListener: ((detail: MobileRelayStatusDetail) => void) | null
const connect = vi.fn().mockResolvedValue(null)
const fetchAuthStatus = vi.fn().mockResolvedValue(null)
@@ -58,7 +58,7 @@ describe('MobilePairingConnectionOptions', () => {
value: {
mobile: {
getRelayStatus: vi.fn().mockResolvedValue({ status: 'registered' }),
onRelayStatusChanged: vi.fn((listener: (status: MobileRelayStatus) => void) => {
onRelayStatusChanged: vi.fn((listener: (detail: MobileRelayStatusDetail) => void) => {
statusListener = listener
return vi.fn()
})
@@ -235,7 +235,35 @@ describe('MobilePairingConnectionOptions', () => {
await user.click(screen.getByRole('radio', { name: /^LAN\b/i }))
expect(onChange).toHaveBeenCalledWith('local-only')
statusListener?.('standby')
statusListener?.({ status: 'standby' })
})
it('names the assigned relay cell by host once the status carries one', async () => {
mocks.state = {
...mocks.state,
orcaProfileAuthStatus: {
activeProfileId: 'profile-1',
configured: true,
state: 'connected',
persistence: 'encrypted'
}
}
render(<MobilePairingConnectionOptions value="automatic" onChange={vi.fn()} />)
expect(screen.queryByTestId('relay-cell-line')).toBeNull()
statusListener?.({ status: 'registered', cellUrl: 'https://c27.relay.example.test' })
await waitFor(() =>
expect(screen.getByTestId('relay-cell-line')).toHaveTextContent(
'Relay cell: c27.relay.example.test'
)
)
// Why: the line is a diagnostic, not an option; it must not join the group.
expect(within(screen.getByRole('radiogroup')).getAllByRole('radio')).toHaveLength(2)
statusListener?.({ status: 'offline' })
await waitFor(() => expect(screen.queryByTestId('relay-cell-line')).toBeNull())
})
it('keeps LAN available while Relay is retrying', async () => {
@@ -6,7 +6,10 @@ import { translate } from '../../i18n/i18n'
import { useAppStore } from '../../store'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
import { cn } from '@/lib/utils'
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
import type {
MobileRelayStatus,
MobileRelayStatusDetail
} from '../../../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import { MobilePairingPathOption } from './MobilePairingPathOption'
@@ -38,6 +41,16 @@ function relayStatusLabel(status: MobileRelayStatus): string {
)
}
// Support needs the cell a slow session actually landed on; the scheme adds
// nothing a reader can act on, so only the host is shown.
function relayCellLabel(cellUrl: string): string | null {
try {
return new URL(cellUrl).host || null
} catch {
return null
}
}
export function MobilePairingConnectionOptions({
value,
onChange,
@@ -56,6 +69,7 @@ export function MobilePairingConnectionOptions({
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const [relayStatus, setRelayStatus] = useState<MobileRelayStatus>('offline')
const [relayCellUrl, setRelayCellUrl] = useState<string | undefined>(undefined)
const signedIn = authStatus?.state === 'connected'
const reconnectRequired = authStatus?.state === 'reconnect-required'
// Why: an unconfigured build has no Relay endpoint to sign into, so a Sign in
@@ -93,22 +107,28 @@ export function MobilePairingConnectionOptions({
optionRefs.current[next]?.focus()
}
const relayCell = relayCellUrl ? relayCellLabel(relayCellUrl) : null
useOrcaProfileAuthStatusRefresh()
useEffect(() => {
let receivedEvent = false
let active = true
const unsubscribe = window.api.mobile.onRelayStatusChanged((status) => {
const apply = (detail: MobileRelayStatusDetail): void => {
setRelayStatus(detail.status)
setRelayCellUrl(detail.cellUrl)
}
const unsubscribe = window.api.mobile.onRelayStatusChanged((detail) => {
receivedEvent = true
if (active) {
setRelayStatus(status)
apply(detail)
}
})
void window.api.mobile
.getRelayStatus()
.then(({ status }) => {
.then((detail) => {
if (active && !receivedEvent) {
setRelayStatus(status)
apply(detail)
}
})
.catch(() => {})
@@ -226,6 +246,18 @@ export function MobilePairingConnectionOptions({
</Button>
</div>
) : null}
{value === 'automatic' && relayCell ? (
<p
className="border-t border-border/60 py-2 pl-10 pr-3 text-xs text-muted-foreground"
data-testid="relay-cell-line"
>
{translate(
'auto.components.settings.MobilePairingConnectionOptions.relayCell',
'Relay cell'
)}
{`: ${relayCell}`}
</p>
) : null}
<div className="border-t border-border" />
<MobilePairingPathOption
selected={value === 'local-only'}
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import SidebarAgentsList from './SidebarAgentsList'
vi.mock('@/components/activity/activity-thread-list-pane', () => ({
ActivityThreadListPane: () => null
}))
beforeEach(() => {
useAppStore.setState({ agentsShowSearch: true })
vi.stubGlobal('api', { ui: { set: vi.fn().mockResolvedValue(undefined) } })
})
afterEach(() => {
cleanup()
document.body.replaceChildren()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('preserves workspace focus on mount and focuses search only when explicitly enabled', async () => {
const workspaceInput = document.createElement('input')
const optionsTarget = document.createElement('div')
document.body.append(workspaceInput, optionsTarget)
workspaceInput.focus()
const setQuery = vi.fn()
const view = render(
<TooltipProvider>
<SidebarAgentsList
readFilter="all"
setReadFilter={vi.fn()}
groupBy="status"
setGroupBy={vi.fn()}
query=""
setQuery={setQuery}
optionsTarget={optionsTarget}
/>
</TooltipProvider>
)
expect(view.getByRole('textbox', { name: 'Search' })).toBeTruthy()
expect(document.activeElement).toBe(workspaceInput)
fireEvent.keyDown(view.getByRole('textbox', { name: 'Search' }), { key: 'Escape' })
expect(useAppStore.getState().agentsShowSearch).toBe(false)
expect(setQuery).toHaveBeenCalledWith('')
expect(view.queryByRole('textbox', { name: 'Search' })).toBeNull()
await act(async () => {
fireEvent.keyDown(view.getByRole('button', { name: 'Thread list options' }), { key: 'Enter' })
})
await act(async () => {
fireEvent.keyDown(view.getByRole('menuitemcheckbox', { name: 'Show search' }), { key: 'Enter' })
})
await waitFor(() => {
expect(document.activeElement).toBe(view.getByRole('textbox', { name: 'Search' }))
})
expect(useAppStore.getState().agentsShowSearch).toBe(true)
expect(window.api.ui.set).toHaveBeenCalledWith({ agentsShowSearch: false })
expect(window.api.ui.set).toHaveBeenCalledWith({ agentsShowSearch: true })
})
@@ -40,24 +40,27 @@ export default function SidebarAgentsList({
}: SidebarAgentsListProps): React.JSX.Element {
// The search row is owned here and mounts conditionally, so subscribe this host to locale changes.
useTranslation()
// Why store-backed: these are persisted preferences (agents* UI fields), unlike the momentary search.
const compactMode = useAppStore((s) => s.agentsCompactMode)
const setCompactMode = useAppStore((s) => s.setAgentsCompactMode)
const showSearch = useAppStore((s) => s.agentsShowSearch)
const setShowSearch = useAppStore((s) => s.setAgentsShowSearch)
const showChildAgents = useAppStore((s) => s.agentsShowChildAgents)
const setShowChildAgents = useAppStore((s) => s.setAgentsShowChildAgents)
const [selectedPaneKey, setSelectedPaneKey] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const activityFilterInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (!searchOpen) {
return
}
// Radix restores focus to the menu trigger after selection; focus on the
// next frame so the newly mounted search field wins that race.
const frame = requestAnimationFrame(() => activityFilterInputRef.current?.focus())
return () => cancelAnimationFrame(frame)
}, [searchOpen])
const handleShowSearchChange = useCallback(
(visible: boolean) => {
setShowSearch(visible)
if (!visible) {
setQuery('')
return
}
// Wait for the newly visible input to mount before focusing it.
requestAnimationFrame(() => activityFilterInputRef.current?.focus())
},
[setQuery, setShowSearch]
)
const {
storeData,
@@ -109,24 +112,22 @@ export default function SidebarAgentsList({
return (
<div className="flex min-h-0 flex-1 flex-col">
{searchOpen ? (
{showSearch ? (
<div className="shrink-0 border-b border-border px-2 py-1.5">
<Input
ref={activityFilterInputRef}
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setSearchOpen(false)
setQuery('')
handleShowSearchChange(false)
}
}}
placeholder={translate(
'auto.components.activity.ActivityPrototypePage.795cbf26e2',
'Filter...'
)}
className="h-7 w-full text-[11px]"
className="h-7 w-full text-[11px] shadow-none focus-visible:ring-0"
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.search',
'Search'
@@ -178,7 +179,8 @@ export default function SidebarAgentsList({
onShowChildAgentsChange={setShowChildAgents}
onMarkAllThreadsRead={markAllThreadsRead}
onClearCompleted={handleClearCompleted}
onSearch={() => setSearchOpen(true)}
showSearch={showSearch}
onShowSearchChange={handleShowSearchChange}
unreadOnly={readFilter === 'unread'}
onToggleUnread={() => setReadFilter(readFilter === 'unread' ? 'all' : 'unread')}
/>,
+3 -1
View File
@@ -11100,7 +11100,8 @@
"signInAgain": "Sign in again for Relay",
"localTitle": "LAN",
"localDescription": "Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.",
"retrying": "Retrying"
"retrying": "Retrying",
"relayCell": "Relay cell"
},
"MobilePairingSetupSection": {
"title": "Pair a phone",
@@ -16226,6 +16227,7 @@
"showChildAgents": "Show child agents",
"activityOptions": "Activity options",
"threadListOptionsFiltered": "Thread list options, filters active",
"showSearch": "Show search",
"interrupted": "Interrupted",
"state": {
"working": "Working",
+1
View File
@@ -14181,6 +14181,7 @@
"795cbf26e2": "Filtrar...",
"4616ea39fd": "Ir al workspace",
"threadListOptionsFiltered": "Opciones de lista de hilos, filtros activos",
"showSearch": "Mostrar búsqueda",
"markThreadRead": "Marcar hilo como leído",
"59b131fbd9": "Marcar hilo como no leído",
"beb2c19173": "No leído",

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