From 6ec0c1affb488c4fb4bfbfa7db3c8bc1347d412c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:12:22 -0700 Subject: [PATCH] fix(serve): prefer explicit --port over stale WS fallback (#8535) When `orca serve --port

` is set, bind the pinned port before any persisted mobile-ws-fallback-port.json entry so clients dialing

are not stranded. Default auto/desktop keep STA-1511 fallback-first. --- src/main/index.ts | 10 +++++- src/main/runtime/rpc/ws-transport.test.ts | 37 +++++++++++++++++++++++ src/main/runtime/rpc/ws-transport.ts | 26 ++++++++++++---- src/main/runtime/runtime-rpc.ts | 16 +++++++--- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 21c4a9ab51e..ded536c3e32 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2119,7 +2119,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 }) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 391e5e18284..ca34340ccb1 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -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

` 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) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 8b88c7a820e..0d5403c5991 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -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

` 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 | 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: 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: 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) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6dd4856b738..9bb1b96c127 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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 @@ -449,6 +453,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 @@ -481,6 +486,7 @@ export class OrcaRuntimeRpcServer { platform = process.platform, enableWebSocket = false, wsPort = DEFAULT_WS_PORT, + preferPinnedWsPort = false, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP @@ -492,6 +498,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 @@ -862,10 +869,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,