Skip to content

fix(remote): two daemons, phone tabs, a split witness, and pairing clock skew - #488

Merged
Lokesh7025 merged 5 commits into
RCfrom
fix/remote-edge-cases
Oct 4, 2026
Merged

Lokesh7025 merged 5 commits into
RCfrom
fix/remote-edge-cases

Conversation

@Lokesh7025

Copy link
Copy Markdown
Contributor

Fixes the edge cases found while testing remote control with several daemons, several phone tabs, and restarts, plus two known bugs.

Two daemons running /remote (the "later tabs silently stop working" report)

Every daemon a user runs (axl, axl --unsafe, another sandbox) has its own state directory and pairing, but they share the installation and so its relay identity. With /remote in two of them, each connection replaced the other's relay route forever: the reproduction showed both relays stuck in reconnecting, the first phone stuck on "Catching up", the second on "Opening session", no error anywhere, and the second daemon receiving the first phone's requests (Unexpected application_request before pairing completed, then identity_mismatch).

Now one daemon at a time serves remote access. It holds a claim file in ~/.axl/remote/<installation>.lock (process and state directory, taken over when the owner has exited). Another daemon's /remote fails with "Remote access is in use by another Axl daemon (process N, state in ...)", before its current pairing is touched; its restore waits for the claim.

Phone tabs

The newest tab always wins, deterministically:

  • a tab listens for takeovers from the moment it starts, so one still starting yields;
  • a new tab repeats its takeover request while it waits, and takes the endpoint lock from a tab that never answers (a background tab the phone suspended), through a new takeOverDeviceEndpoint in the browser binding; that tab's endpoint fails closed exactly as for any lost lock;
  • the owner is marked in localStorage, so a tab that wakes up later stops instead of reconnecting over the new one;
  • an older tab never takes the pairing back from a newer one; "Use this tab" reloads, which makes it the newest;
  • a tab that let go keeps showing why, instead of an error from the requests it cut off.

Witness split by a crash

The gateway writes each step to all three replicas, and the endpoint needs all three receipts. A crash part way left some replicas a step ahead, and recovery refused that lineage forever, so the pairing was dead until a re-pair. Before recovery the gateway now brings the replicas left behind forward by the same endpoint-signed step (catchUpLineage), only for an unrecovered lineage, only as a direct successor of the replica's own head, and only when fresh heads both other replicas signed for one endpoint-signed read sit at that head or that successor. A missed registration completes as an ordinary registration. No replica moves backward; an endpoint restored from before its pending step is still caught as a fork, and replicas holding two different successors stay unrecovered.

Browser pairing flake, and a real clock-skew bug behind it

The intermittent internal_error: join in the Chrome and Safari barrier jobs (6 of the last 100 CI runs) was a KeyPackage lifetime check: the test peer's clock is captured before the device creates its KeyPackage, and when a second boundary fell between them the KeyPackage looked not yet valid. The same strictness affects real pairing: a phone whose clock is even a second ahead of the daemon (WSL clocks drift after sleep) had its KeyPackage refused by OpenMLS, and a phone behind the daemon refused the invitation. The device now starts its KeyPackage lifetime two minutes early (as OpenMLS's default lifetimes do) and accepts an invitation issued up to two minutes ahead of its clock. The daemon accepts the new 12-minute span and the old 10-minute one. Each side's checks against its own clock stay exact. The barrier scenario now runs its peer 1.5 s behind on purpose.

Smaller

  • A request that overtakes the pairing activation (the relay lost the activation) is dropped quietly instead of being recorded as a pairing failure; the phone resends both.
  • remote-hosted-path.md listed gates that are long settled; it now records how each was settled.

Tests

  • Phone E2E: 17/17 locally, with three new scenarios: tabs opened back to back, a frozen tab with no tab channel (the takeover path ran), and a second daemon's refused /remote.
  • Witness: crash after one and two replicas, a split registration, rollback still caught after a repair, and a two-successor split staying unrecovered.
  • Runtime: the claim (live owner, exited owner, release safety) and the refused /remote.
  • Rust: phone clock ahead within and beyond the skew; device clock behind at the edge of the skew.

The gateway writes each step to all three replicas and the endpoint
needs all three receipts. A control plane that stopped part way left
some replicas one step ahead, and lineage recovery then refused that
lineage forever, so its pairing never worked again until a re-pair.

Before recovery, the gateway now brings the replicas left behind
forward by the same endpoint-signed step. A replica takes it only for a
lineage it has not recovered, only as a direct successor of its own
head, and only when fresh heads both other replicas signed for one
endpoint-signed read sit at its head or that successor. A missed
registration completes as an ordinary registration. Heads for the
comparison come from currentHead, which does not keep the answer as the
read's response, so the same read sees the new head afterwards.

No replica moves backward and every head is one the endpoint signed:
an endpoint restored from before its pending step is still caught as a
fork, and replicas holding two different successors stay unrecovered.

Signed-off-by: Lokesh <lokeshselvam7025@gmail.com>
Two daemons for one user (axl and axl --unsafe keep separate state)
share the installation's relay identity. With /remote in both, each
replaced the other's relay route forever: both phones hung without an
error and each daemon received the other phone's requests. The daemon
serving remote access now holds a claim file under ~/.axl/remote, and
another daemon's /remote is refused with its process and state
directory before its own pairing is touched; its restore waits.

On the phone, the tab opened last always wins. A tab listens for
takeovers from the start, a new tab repeats its request while it
waits and then takes the endpoint lock from a tab that never answers
(a suspended background tab) through the binding's new
takeOverDeviceEndpoint, and the owner is marked in localStorage so a
tab that wakes later stops instead of reconnecting over the new one.
A tab that let go keeps saying why.

A request that overtakes the pairing activation is dropped quietly
instead of being recorded as a failure; the phone resends both.

The phone E2E gains three scenarios: tabs opened back to back, a
frozen tab with no tab channel, and a second daemon's refused /remote.

Signed-off-by: Lokesh <lokeshselvam7025@gmail.com>
A daemon refused the KeyPackage of a phone whose clock was even a
second ahead of its own (OpenMLS checks that the lifetime has begun),
and a phone refused an invitation issued after its own clock read. WSL
clocks drift after sleep, so real pairings could fail with an opaque
error. The same check was the intermittent join failure in the Chrome
and Safari barrier jobs: the test peer's clock is captured before the
device creates its KeyPackage.

The device now starts its KeyPackage lifetime two minutes early, as
OpenMLS's default lifetimes do, and still ends it ten minutes from now;
the daemon accepts that span and the exact ten-minute span of older
devices. The device accepts an invitation issued up to two minutes
ahead of its clock. Each side's checks against its own clock stay
exact. The barrier scenario runs its peer 1.5 s behind on purpose.

Signed-off-by: Lokesh <lokeshselvam7025@gmail.com>
…pairing skew

The hosted-path checkpoint now records how each of its gates was
settled instead of listing them as open.

Signed-off-by: Lokesh <lokeshselvam7025@gmail.com>
@Lokesh7025
Lokesh7025 requested a review from Haz3-jolt as a code owner October 4, 2026 07:17
@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2804d3ef-f372-4f72-8918-ebd37bcfb249

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

A tab that took the endpoint lock from a tab that never let go tried to
open the endpoint once more and gave up if the lock still looked held.
The lock manager can see the taken lock's release after the worker's
next request, and on a slow runner the new tab then said the pairing
was open elsewhere. The tab now keeps trying for the handoff window
after the takeover, taking the lock again every few attempts.

Signed-off-by: Lokesh <lokeshselvam7025@gmail.com>
@Lokesh7025
Lokesh7025 merged commit 44ff51e into RC Oct 4, 2026
37 checks passed
@Lokesh7025
Lokesh7025 deleted the fix/remote-edge-cases branch October 4, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant