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,