fix(serve): prefer explicit --port over stale WS fallback (#8535) (#9005)

When `orca serve --port <P>` is set, bind the pinned port before any
persisted mobile-ws-fallback-port.json entry so clients dialing <P>
are not stranded. Default auto/desktop keep STA-1511 fallback-first.
This commit is contained in:
Neil 2026-07-16 17:24:28 -07:00 committed by GitHub
parent 655f846d2c
commit 1cdc8e85b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 78 additions and 11 deletions

View File

@ -2178,7 +2178,15 @@ app.whenReady().then(async () => {
enableWebSocket: true,
...(isE2E ? { wsPort: 0 } : {}),
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}),
...(serveOptions?.wsPort !== undefined
? {
wsPort: serveOptions.wsPort,
// Why: only an explicit `orca serve --port` pin prefers the requested
// port over a stale STA-1511 fallback (issue #8535). Default 6768 /
// dev 6769 keep fallback-first so mobile pairings stay stable.
preferPinnedWsPort: true
}
: {}),
webClientRoot: getBundledWebClientRoot()
})
registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus })

View File

@ -472,6 +472,43 @@ describe('WebSocketTransport', () => {
expect(transport.resolvedPort).toBe(fallbackPort)
})
it('binds the preferred port first when preferPinnedPort is set and both are free', async () => {
// Why: issue #8535 — `orca serve --port <P>` clients dial the pin. A
// free but stale mobile-ws-fallback-port.json must not pre-empt it.
const preferredPort = await reserveFreePort()
const fallbackPort = await reserveFreePort()
const transport = new WebSocketTransport({
host: '127.0.0.1',
port: preferredPort,
fallbackPort,
preferPinnedPort: true
})
transports.push(transport)
await transport.start()
expect(transport.resolvedPort).toBe(preferredPort)
})
it('falls back when preferPinnedPort is set but the preferred port is taken', async () => {
// Why: explicit pins still degrade to the STA-1511 fallback on
// EADDRINUSE so previously-paired mobile devices remain reachable.
const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 })
transports.push(preferredHolder)
await preferredHolder.start()
const preferredPort = preferredHolder.resolvedPort
const fallbackPort = await reserveFreePort()
const transport = new WebSocketTransport({
host: '127.0.0.1',
port: preferredPort,
fallbackPort,
preferPinnedPort: true
})
transports.push(transport)
await transport.start()
expect(transport.resolvedPort).toBe(fallbackPort)
})
it('binds the preferred port when the persisted fallback is taken', async () => {
const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 })
transports.push(fallbackHolder)

View File

@ -54,6 +54,11 @@ export type WebSocketTransportOptions = {
// the (now free) preferred port instead would strand those pairings
// (STA-1511). Callers pass the previously assigned fallback port here.
fallbackPort?: number
// Why: `orca serve --port <P>` clients dial the pinned port. Prefer that port
// first (fallback second) so a stale mobile-ws-fallback-port.json cannot
// silently steal the pin (issue #8535). Default auto/desktop keeps
// fallback-first for STA-1511 pairing stability.
preferPinnedPort?: boolean
}
export class WebSocketTransport implements RpcTransport {
@ -65,6 +70,7 @@ export class WebSocketTransport implements RpcTransport {
private readonly preAuthTimeoutMs: number
private readonly staticRoot: string | undefined
private readonly fallbackPort: number | undefined
private readonly preferPinnedPort: boolean
private httpServer: HttpsServer | HttpServer | null = null
private wss: WebSocketServer | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
@ -89,7 +95,8 @@ export class WebSocketTransport implements RpcTransport {
heartbeatIntervalMs,
preAuthTimeoutMs,
staticRoot,
fallbackPort
fallbackPort,
preferPinnedPort
}: WebSocketTransportOptions) {
this.host = host
this.port = port
@ -99,6 +106,7 @@ export class WebSocketTransport implements RpcTransport {
this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS
this.staticRoot = staticRoot
this.fallbackPort = fallbackPort
this.preferPinnedPort = preferPinnedPort === true
}
onMessage(handler: WebSocketMessageHandler): void {
@ -150,10 +158,12 @@ export class WebSocketTransport implements RpcTransport {
return
}
// Why: a persisted fallback port is bound FIRST — devices paired while it
// was active store ws://ip:<fallback> and would be permanently stranded if
// a later launch grabbed the (now free) preferred port instead (STA-1511).
// Without a persisted fallback the preferred port is tried first. On
// Why: default order binds a persisted fallback FIRST — devices paired
// while it was active store ws://ip:<fallback> and would be stranded if a
// later launch grabbed the (now free) preferred port instead (STA-1511).
// Explicit serve --port flips the order so the pinned port wins when free
// (issue #8535); fallback remains a secondary candidate for EADDRINUSE.
// Without a persisted fallback only the preferred port is tried. On
// EADDRINUSE each candidate falls through to the next, ending at port 0
// (OS-assigned) so mobile pairing still works when everything is taken.
// The QR code reads resolvedPort after start, so it always advertises the
@ -163,7 +173,11 @@ export class WebSocketTransport implements RpcTransport {
? this.fallbackPort
: undefined
const candidatePorts =
persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port]
persistedFallbackPort === undefined
? [this.port]
: this.preferPinnedPort
? [this.port, persistedFallbackPort]
: [persistedFallbackPort, this.port]
for (const port of candidatePorts) {
try {
await this.tryListen(port)

View File

@ -53,6 +53,10 @@ type OrcaRuntimeRpcServerOptions = {
platform?: NodeJS.Platform
enableWebSocket?: boolean
wsPort?: number
// Why: true when the caller set an explicit port (e.g. `orca serve --port`).
// Distinguishes that pin from the DEFAULT_WS_PORT default so transport bind
// order can prefer the pin over a stale STA-1511 fallback (issue #8535).
preferPinnedWsPort?: boolean
webClientRoot?: string
// Why: test-only overrides for the two time-bound constants below.
// Production callers must not pass these — defaults are set by the design
@ -450,6 +454,7 @@ export class OrcaRuntimeRpcServer {
private readonly platform: NodeJS.Platform
private readonly enableWebSocket: boolean
private readonly wsPort: number
private readonly preferPinnedWsPort: boolean
private readonly webClientRoot: string | undefined
private readonly authToken = randomBytes(24).toString('hex')
private readonly keepaliveIntervalMs: number
@ -482,6 +487,7 @@ export class OrcaRuntimeRpcServer {
platform = process.platform,
enableWebSocket = false,
wsPort = DEFAULT_WS_PORT,
preferPinnedWsPort = false,
webClientRoot,
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
longPollCap = LONG_POLL_CAP
@ -493,6 +499,7 @@ export class OrcaRuntimeRpcServer {
this.platform = platform
this.enableWebSocket = enableWebSocket
this.wsPort = wsPort
this.preferPinnedWsPort = preferPinnedWsPort
this.webClientRoot = webClientRoot
this.keepaliveIntervalMs = keepaliveIntervalMs
this.longPollCap = longPollCap
@ -863,10 +870,11 @@ export class OrcaRuntimeRpcServer {
staticRoot: this.webClientRoot,
// Why: keep the fallback port stable across restarts so paired
// devices' stored endpoints stay valid (STA-1511) — the transport
// binds a persisted fallback before the preferred port. wsPort 0
// means the caller explicitly wants a random port (E2E) — don't
// pin it.
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {})
// binds a persisted fallback before the preferred port unless the
// caller explicitly pinned a port (serve --port). wsPort 0 means
// the caller wants a random port (E2E) — don't pin it.
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}),
...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {})
})
const mobileSocketWiring = new MobileSocketWiring({
deviceRegistry: this.deviceRegistry,