From 35029af1938f06bc87f2f271614bfe166897333f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 24 Aug 2026 15:29:51 -0400 Subject: [PATCH] fix(mobile): preserve relay close recovery codes --- .../transport/mobile-relay-e2ee-link.test.ts | 110 ++++++++++++++++++ .../src/transport/mobile-relay-e2ee-link.ts | 30 ++++- .../mobile-relay-physical-client.test.ts | 51 ++++++++ .../transport/mobile-relay-physical-client.ts | 24 +++- 4 files changed, 211 insertions(+), 4 deletions(-) diff --git a/mobile/src/transport/mobile-relay-e2ee-link.test.ts b/mobile/src/transport/mobile-relay-e2ee-link.test.ts index d9093568fc1..aa2d42b13f0 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.test.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.test.ts @@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => { ) expect(socket.close).toHaveBeenCalledOnce() }) + + it('keeps a typed close code when transport error precedes close', () => { + const socket = new ThrowingSocket() + const onError = vi.fn() + 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, + createSocket: () => socket as unknown as WebSocket + }) + + socket.onerror?.() + socket.onclose?.({ code: 4409 }) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' })) + }) + + it('classifies an opaque close after transport error as 1006', () => { + const socket = new ThrowingSocket() + const onError = vi.fn() + 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, + createSocket: () => socket as unknown as WebSocket + }) + + socket.onerror?.() + socket.onclose?.({ code: 0 }) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' })) + }) + + it('bounds an error when the platform never emits close', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + 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, + createSocket: () => socket as unknown as WebSocket + }) + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' })) + link.close() + } finally { + vi.useRealTimers() + } + }) + + it('cancels the missing-close timer when explicitly closed', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + 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, + createSocket: () => socket as unknown as WebSocket + }) + socket.onerror?.() + link.close() + await vi.advanceTimersByTimeAsync(250) + + expect(onError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 80d91589c53..f19417a60f1 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +// Native WebSockets normally emit close immediately after error; bound the +// missing-close case so a dead socket cannot leave recovery pending forever. +const RELAY_ERROR_CLOSE_GRACE_MS = 250 + export class RelayOuterError extends Error { constructor(readonly code: number) { super(`relay_outer_${code}`) @@ -32,6 +36,7 @@ export class MobileRelayE2eeLink { private readonly channel: MobileE2EEV2PhysicalChannel private outerReady = false private closed = false + private transportErrorTimer: ReturnType | null = null private inboundChain: Promise = Promise.resolve() constructor(options: MobileRelayE2eeLinkOptions) { @@ -70,6 +75,10 @@ export class MobileRelayE2eeLink { return } this.closed = true + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } this.channel.dispose() this.socket.close() } @@ -103,8 +112,21 @@ export class MobileRelayE2eeLink { }) .catch((error: unknown) => this.fail(asError(error))) } - this.socket.onerror = () => this.fail(new Error('relay transport error')) - this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006)) + // `error` is often delivered just before `close`; wait for close so a + // typed relay code is not replaced by a generic transport error. + this.socket.onerror = () => { + this.transportErrorTimer ??= setTimeout(() => { + this.transportErrorTimer = null + this.fail(new RelayOuterError(1006)) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + this.socket.onclose = (event) => { + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } + this.fail(new RelayOuterError(event.code || 1006)) + } } private acceptHello(raw: unknown): void { @@ -137,6 +159,10 @@ export class MobileRelayE2eeLink { return } this.closed = true + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } this.channel.dispose() this.options.onError(error) this.socket.close() diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index 77972cc6dfd..cf59264a407 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => { expect(fakes.start).not.toHaveBeenCalled() }) + it('keeps the typed close code when transport error precedes close', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.onerror?.() + socket.onclose?.({ code: 4409 }) + + await expect(status).rejects.toEqual(new RelayOuterError(4409)) + }) + + it('classifies an opaque close after transport error as 1006', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.onerror?.() + socket.onclose?.({ code: 0 }) + + await expect(status).rejects.toEqual(new RelayOuterError(1006)) + }) + + it('settles after an error when the platform never emits close', async () => { + vi.useFakeTimers() + try { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006)) + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + + await rejected + } finally { + vi.useRealTimers() + } + }) + it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => { const socket = new FakeSocket() const entries: ConnectionLogEntry[] = [] diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 37b5d64857e..9e3e4c259f8 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' +const RELAY_ERROR_CLOSE_GRACE_MS = 250 + type PendingRequest = { resolve: (response: RpcResponse) => void reject: (error: Error) => void @@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: { let requestCounter = 0 let closed = false let intentionallyClosed = false + let transportErrorTimer: ReturnType | null = null let outerReady = false let authenticated = false let resolveAuthenticated!: () => void @@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: { }) .catch((error: unknown) => fail(asError(error))) } - socket.onerror = () => fail(new Error('relay transport error')) - socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006)) + // WebSocket implementations commonly emit `error` immediately before + // `close`; the bounded fallback represents an opaque 1006 close. + socket.onerror = () => { + transportErrorTimer ??= setTimeout(() => { + transportErrorTimer = null + fail(new RelayOuterError(1006)) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + socket.onclose = (event) => { + if (transportErrorTimer) { + clearTimeout(transportErrorTimer) + transportErrorTimer = null + } + fail(new RelayOuterError(event.code || 1006)) + } function acceptRelayHello(raw: unknown): void { if (typeof raw !== 'string') { @@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: { return } closed = true + if (transportErrorTimer) { + clearTimeout(transportErrorTimer) + transportErrorTimer = null + } if (intentionallyClosed) { log('info', 'Relay: pairing socket closed', cellHost) } else {