mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta4554-pane-identity
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
+142
@@ -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')}
|
||||
/>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user