fix(mobile): preserve relay close recovery codes

This commit is contained in:
Jinwoo-H
2026-08-24 15:29:51 -04:00
parent 171c1b3e7f
commit 35029af193
4 changed files with 211 additions and 4 deletions
@@ -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()
}
})
})
+28 -2
View File
@@ -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<typeof setTimeout> | null = null
private inboundChain: Promise<void> = 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()
@@ -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[] = []
@@ -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<typeof setTimeout> | 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 {