mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
fix(mobile): preserve relay close recovery codes
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user