From 9648d63a9d2ae29d940230cdc49555820a39fee7 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:58:23 +0000 Subject: [PATCH] fix: end the one-time phone's pre-outcome waits at the room's deadline Closes #914 --- docs/specs/one-time.md | 2 ++ lib/src/remote/client/one-time-client.test.ts | 12 ++++++++++ lib/src/remote/client/one-time-client.ts | 23 ++++++++++++++++--- scripts/spec-word-budgets.json | 2 +- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index acc0f8835..9df7160b8 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -217,6 +217,8 @@ at most one session**, on `ClientSessionCore`, direct or not at all. - **Never open a socket outside `connectOnce`**, which runs once per client and opens none for an expired link. +- **Must end steps 1–2 at the room's hard deadline**, WebCrypto and the + socket's open included; step 3 runs on the direct deadline instead. - **Never import store, passkey, push, or worker code** (the static's rule: `docs/specs/remote-security-model.md` -> "One-time connection"). - Every protocol-v1 method refuses until both directions are direct (Direct diff --git a/lib/src/remote/client/one-time-client.test.ts b/lib/src/remote/client/one-time-client.test.ts index bbfb3e257..894b019d1 100644 --- a/lib/src/remote/client/one-time-client.test.ts +++ b/lib/src/remote/client/one-time-client.test.ts @@ -16,6 +16,7 @@ import { ONE_TIME_DENIAL_CODES, DIRECT_ONLY_DEADLINE_MS, DIRECT_SETUP_TIMEOUT_MS, + ONE_TIME_EXPIRY_GRACE_MS, ONE_TIME_LINK_TTL_MS, RELAY_PING, RELAY_PING_INTERVAL_MS, @@ -538,6 +539,17 @@ describe('OneTimeClient: the confirmation', () => { message: ONE_TIME_UNREACHABLE_MESSAGE, }); }); + + it('ends a socket that never opens at the room’s hard deadline, as the link expiring', async () => { + const client = makeClient({ open: false }); + const burrow = await ScriptedBurrow.create(); + const result = client.connectOnce(burrow.link, LABEL, () => {}); + await flushUntil(() => sockets[0]); + clock.advance(burrow.link.expiry * 1000 + ONE_TIME_EXPIRY_GRACE_MS - clock.now()); + expect(await result).toEqual({ ok: false, message: ONE_TIME_LINK_EXPIRED_MESSAGE }); + expect(phoneSocket().closeCode).toBe(1000); + expect(clock.armed).toBe(0); + }); }); describe('OneTimeClient: the direct path', () => { diff --git a/lib/src/remote/client/one-time-client.ts b/lib/src/remote/client/one-time-client.ts index 7a225fe7f..57755a900 100644 --- a/lib/src/remote/client/one-time-client.ts +++ b/lib/src/remote/client/one-time-client.ts @@ -143,6 +143,11 @@ export class OneTimeClient implements RemoteAdapterClient { #route: OneTimeRoute | null = null; /** The link `connectOnce` was given, so a close can tell whether it had expired. */ #link: OneTimeLink | null = null; + /** + * Cancels the timer armed at the room's hard deadline, which bounds every + * wait before the outcome — WebCrypto and the socket's open included. + */ + #cancelDeadline: (() => void) | null = null; /** The rendezvous socket, while this client still reads it; closed at the switch and at the end. */ readonly #rendezvous: RendezvousHold; @@ -230,6 +235,10 @@ export class OneTimeClient implements RemoteAdapterClient { this.#link = link; // The room's hard deadline: no answer can arrive after it. const deadline = link.expiry * 1000 + ONE_TIME_EXPIRY_GRACE_MS; + this.#cancelDeadline = this.#setTimer( + () => this.#fail(ONE_TIME_LINK_EXPIRED_MESSAGE), + Math.max(0, deadline - this.#now()), + ); try { const session = await this.#handshake(link, route, deadline); const code = samplePairingCode(); @@ -248,6 +257,8 @@ export class OneTimeClient implements RemoteAdapterClient { if (this.#failure !== null) throw new Error(this.#failure); // The digits have done their job: the screen moves on to the direct path. onConfirmed?.(); + // From here the direct path's own deadline bounds the wait. + this.#clearDeadline(); this.#phase = 'connecting'; // After the outcome and never before: `establish` builds the direct // path, and a peer connection that existed ahead of authorization would @@ -307,9 +318,9 @@ export class OneTimeClient implements RemoteAdapterClient { /** * One wait in the ceremony that the core does not own — WebCrypto, the * socket's open, the switch — cut short by the first failure: a room that - * closed, a direct path given up, {@link close}. **Checked again after it - * settles**, since a step can finish in the same turn as the failure that - * makes its result moot. + * closed, the room's hard deadline, a direct path given up, {@link close}. + * **Checked again after it settles**, since a step can finish in the same + * turn as the failure that makes its result moot. */ async #race(step: Promise): Promise { const value = await Promise.race([step, this.#interrupted]); @@ -325,6 +336,11 @@ export class OneTimeClient implements RemoteAdapterClient { this.#core.rejectAll(new Error(message)); } + #clearDeadline(): void { + this.#cancelDeadline?.(); + this.#cancelDeadline = null; + } + /** End the attempt with `message`, and release everything. */ #finish(message: string): OneTimeResult { this.#failure = message; @@ -513,6 +529,7 @@ export class OneTimeClient implements RemoteAdapterClient { #teardown(): void { if (this.#phase === 'ended') return; this.#phase = 'ended'; + this.#clearDeadline(); this.#rendezvous.close(); this.#core.endSession(this.#failure ?? ONE_TIME_ENDED_MESSAGE, { notifyGone: false }); } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 238186ff8..95789005c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -16,7 +16,7 @@ "docs/specs/layout.md": 12050, "docs/specs/mobile-terminal-ui.md": 2300, "docs/specs/mouse-and-clipboard.md": 5250, - "docs/specs/one-time.md": 3850, + "docs/specs/one-time.md": 3900, "docs/specs/pocket-app.md": 5200, "docs/specs/relay.md": 11100, "docs/specs/remote-api.md": 5300,