Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
37 changes: 37 additions & 0 deletions src/main/runtime/rpc/ws-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 20 additions & 6 deletions src/main/runtime/rpc/ws-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -89,7 +95,8 @@ export class WebSocketTransport implements RpcTransport {
heartbeatIntervalMs,
preAuthTimeoutMs,
staticRoot,
fallbackPort
fallbackPort,
preferPinnedPort
}: WebSocketTransportOptions) {
this.host = host
this.port = port
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
16 changes: 12 additions & 4 deletions src/main/runtime/runtime-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading