From 52fec0b1375ba03873ed45e1fb998c3592a30c01 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:18:37 -0700 Subject: [PATCH] fix(mobile): clear the WS fallback only on an observed reserved-range refusal (STA-4859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review narrowed the clear from an inference to observed evidence. Deducing "the fallback is dead" from `resolvedPort === wsPort` was wrong twice over: the transport falls through the fallback candidate on ANY error, so an EMFILE or unclassified failure cleared a perfectly good pairing; and a transient EADDRINUSE — an outbound ephemeral socket is enough on a 0.0.0.0 bind — is a realistic one-launch event that STA-1511 already self-heals on the next launch. Mobile has no recovery for a dropped endpoint other than re-pairing, so a keep is far cheaper than a wrong clear. WebSocketTransport now records whether the persisted fallback candidate was refused with EACCES + syscall 'listen' + matching port — the Windows reserved range signature, which stays refused for the whole boot — and exposes it as `persistedFallbackListenDenied`. The existing EACCES branch of isPortListenFallbackError is extracted into isListenPermissionDenied so the predicate has one definition. syncPersistedWsFallback takes the transport and requires that signal before clearing; occupied and unclassified failures now keep the file. Also: clearWsFallbackPort warns on failure (a silent failed clear re-arms the flip-flop with no trace), and recoverWebSocketBindAfterFailedWiden documents why it deliberately does not sync — it restores a degraded loopback-only listener that serves no pairing, so persisting its port would overwrite the fallback paired devices still dial. --- .../rpc/ws-fallback-port-store.test.ts | 9 +- .../runtime/rpc/ws-fallback-port-store.ts | 13 +- src/main/runtime/rpc/ws-transport.ts | 26 +- ...untime-rpc-ws-fallback-convergence.test.ts | 266 +++++++++++++----- src/main/runtime/runtime-rpc.ts | 24 +- 5 files changed, 235 insertions(+), 103 deletions(-) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.test.ts b/src/main/runtime/rpc/ws-fallback-port-store.test.ts index c2c844d53e7..bc7ca6b7b32 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.test.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { clearWsFallbackPort, readWsFallbackPort, @@ -47,9 +47,14 @@ describe('ws-fallback-port-store', () => { it('clearing is a no-op when nothing is persisted and survives an unremovable path', () => { const userDataPath = makeUserDataPath() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(() => clearWsFallbackPort(userDataPath)).not.toThrow() - // Why: a directory at the file's path makes rmSync throw — clearing is best-effort like the write. + expect(warnSpy).not.toHaveBeenCalled() + // Why: a directory at the file's path makes rmSync throw — clearing is best-effort like the write, but + // must leave a trace, since a silently kept file re-arms the flip-flop it was meant to end. mkdirSync(join(userDataPath, 'mobile-ws-fallback-port.json')) expect(() => clearWsFallbackPort(userDataPath)).not.toThrow() + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() }) }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index 7154c8a25a8..7efaa882b88 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -44,13 +44,14 @@ export function writeWsFallbackPort(userDataPath: string, port: number): void { } } -// Why: a fallback that failed to bind while the configured port recovered is obsolete — leaving it -// re-arms a dead endpoint next launch, so the advertised port flip-flops (STA-4859, seen with -// Windows Hyper-V reserved ranges that move across reboots). +// Why: a fallback the OS refused to listen on is obsolete — leaving it re-arms a dead endpoint next +// launch and flip-flops the advertised port (STA-4859, Windows reserved ranges that move across reboots). export function clearWsFallbackPort(userDataPath: string): void { + const target = join(userDataPath, FALLBACK_PORT_FILE) try { - rmSync(join(userDataPath, FALLBACK_PORT_FILE), { force: true }) - } catch { - // Why: best-effort like the write — a stale file costs one more convergence pass, not a startup failure. + rmSync(target, { force: true }) + } catch (error) { + // Why: best-effort like the write, but a silent failure re-arms the flip-flop — say so. + console.warn(`[ws-fallback-port-store] Failed to clear ${target}:`, error) } } diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index ec58f8697ae..97c5ad7a8cc 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -63,6 +63,7 @@ export class WebSocketTransport implements RpcTransport { private wsClientIds = new Map() private heartbeatConnections = new Set() private preAuthTimers = new WeakMap>() + private fallbackListenDenied = false constructor({ host, @@ -127,6 +128,12 @@ export class WebSocketTransport implements RpcTransport { return this.port } + // Why: reports that the OS refused a listen on the persisted fallback (reserved range), not merely that + // the bind failed — callers need the distinction to tell a dead port from a momentarily busy one. + get persistedFallbackListenDenied(): boolean { + return this.fallbackListenDenied + } + // Why: the actual OS-reported bind interface, so callers can verify loopback vs all-interfaces (STA-2370). get resolvedHost(): string | null { const addr = this.httpServer?.address() @@ -137,6 +144,7 @@ export class WebSocketTransport implements RpcTransport { if (this.wss) { return } + this.fallbackListenDenied = false // Why: bind a persisted fallback first so devices paired to it aren't stranded (STA-1511); serve --port flips to pinned-first (issue #8535); on failure each candidate falls through to OS-assigned port 0. const persistedFallbackPort = @@ -155,10 +163,9 @@ export class WebSocketTransport implements RpcTransport { return } catch (error: unknown) { // Why: a persisted fallback may fail for any reason, while configured ports fall through only when their listen is occupied or denied. - if ( - port !== persistedFallbackPort && - (!isPortListenFallbackError(error, port) || port === 0) - ) { + if (port === persistedFallbackPort) { + this.fallbackListenDenied = isListenPermissionDenied(error, port) + } else if (!isPortListenFallbackError(error, port) || port === 0) { throw error } console.warn( @@ -340,10 +347,15 @@ function isPortListenFallbackError(error: unknown, port: number): boolean { if (!(error instanceof Error) || !('code' in error)) { return false } - if (error.code === 'EADDRINUSE') { - return true - } + return error.code === 'EADDRINUSE' || isListenPermissionDenied(error, port) +} + +// Why: the OS reserving the port itself (Windows Hyper-V excluded ranges) rather than another process +// holding it — the former stays refused for this boot, the latter frees up on its own. +function isListenPermissionDenied(error: unknown, port: number): boolean { return ( + error instanceof Error && + 'code' in error && error.code === 'EACCES' && 'syscall' in error && error.syscall === 'listen' && diff --git a/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts b/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts index 1df84663238..22cc6f41995 100644 --- a/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts +++ b/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts @@ -25,6 +25,7 @@ vi.mock('../git/worktree', () => { }) const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' +const ALL_INTERFACES = '0.0.0.0' const heldSockets: Server[] = [] let restoreListen: (() => void) | null = null @@ -52,40 +53,39 @@ function seedFallback(userDataPath: string, port: number): void { writeFileSync(fallbackFile(userDataPath), JSON.stringify({ port }), 'utf8') } -async function reserveFreePort(): Promise { - const socket = createServer() - await new Promise((resolve) => socket.listen(0, '127.0.0.1', () => resolve())) - const { port } = socket.address() as AddressInfo - await new Promise((resolve) => socket.close(() => resolve())) - return port -} +type ReservedPort = { port: number; release: () => Promise } -async function holdPort(port: number): Promise { +// Why: the reserving socket keeps listening until the caller is ready, so nothing else on the machine can +// take the port in between — picking a port and closing immediately is a TOCTOU flake under load. +async function reservePort(): Promise { const socket = createServer() heldSockets.push(socket) - await new Promise((resolve, reject) => { - socket.once('error', reject) - socket.listen(port, '127.0.0.1', () => resolve()) - }) - return socket + await new Promise((resolve) => socket.listen(0, '127.0.0.1', () => resolve())) + const { port } = socket.address() as AddressInfo + return { + port, + release: async () => { + heldSockets.splice(heldSockets.indexOf(socket), 1) + await new Promise((resolve) => socket.close(() => resolve())) + } + } } -async function releasePort(socket: Server): Promise { - heldSockets.splice(heldSockets.indexOf(socket), 1) - await new Promise((resolve) => socket.close(() => resolve())) -} - -// Why: reproduce a host that refuses one specific port (Windows Hyper-V excluded range) without needing one. +// Why: reproduce host-level bind refusals (reserved ranges, descriptor exhaustion) that cannot be staged +// with a real socket. function blockListen( - isBlocked: (port: number) => boolean, - makeError: (port: number) => Error + isBlocked: (port: number, host: string) => boolean, + makeError: (port: number, host: string) => Error ): () => void { const prototype = WebSocketTransport.prototype as unknown as { tryListen: (port: number) => Promise + host: string } const original = prototype.tryListen prototype.tryListen = function (port: number): Promise { - return isBlocked(port) ? Promise.reject(makeError(port)) : original.call(this, port) + return isBlocked(port, this.host) + ? Promise.reject(makeError(port, this.host)) + : original.call(this, port) } return () => { prototype.tryListen = original @@ -100,6 +100,14 @@ function listenDenied(port: number): Error { }) } +function portInUse(port: number): Error { + return Object.assign(new Error(`listen EADDRINUSE: address already in use ${port}`), { + code: 'EADDRINUSE', + syscall: 'listen', + port + }) +} + async function startRuntime(options: { userDataPath: string wsPort: number @@ -122,73 +130,109 @@ function servedPort(server: OrcaRuntimeRpcServer): number | null { } describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () => { - it('drops a fallback that failed to bind while the configured port served', async () => { + it('drops a fallback the OS refused to listen on once the configured port served', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const userDataPath = makeUserDataPath() - const configuredPort = await reserveFreePort() - const deadFallbackPort = await reserveFreePort() - seedFallback(userDataPath, deadFallbackPort) - restoreListen = blockListen((port) => port === deadFallbackPort, listenDenied) + const configured = await reservePort() + const reserved = await reservePort() + seedFallback(userDataPath, reserved.port) + restoreListen = blockListen((port) => port === reserved.port, listenDenied) + await configured.release() - const server = await startRuntime({ userDataPath, wsPort: configuredPort }) + const server = await startRuntime({ userDataPath, wsPort: configured.port }) try { - expect(servedPort(server)).toBe(configuredPort) - // Why: the persisted state must converge on what is actually served — keeping the dead fallback is - // what re-armed it on the next launch and made the advertised endpoint flip-flop. + expect(servedPort(server)).toBe(configured.port) + // Why: an EACCES listen means the OS itself holds the port for this boot, so the pairing behind it is + // already dead — keeping the file is what re-armed it and flip-flopped the advertised endpoint. expect(existsSync(fallbackFile(userDataPath))).toBe(false) } finally { await server.stop() } - // Why: the excluded range moved after a reboot, so the old fallback is bindable again. Fallback-first - // order (STA-1511) would re-take it and strand every device paired to the configured port. + // Why: the reserved range moved after a reboot, so the old port is bindable again. Fallback-first order + // (STA-1511) would re-take it and strand every device paired to the configured port. restoreListen() restoreListen = null - const relaunched = await startRuntime({ userDataPath, wsPort: configuredPort }) + await reserved.release() + const relaunched = await startRuntime({ userDataPath, wsPort: configured.port }) try { - expect(servedPort(relaunched)).toBe(configuredPort) + expect(servedPort(relaunched)).toBe(configured.port) expect(existsSync(fallbackFile(userDataPath))).toBe(false) } finally { await relaunched.stop() } }) - it('drops a fallback occupied by another process once the configured port serves', async () => { + it('keeps a fallback that was merely occupied and re-binds it once free', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const userDataPath = makeUserDataPath() - const configuredPort = await reserveFreePort() - const busyFallbackPort = await reserveFreePort() - const squatter = await holdPort(busyFallbackPort) - seedFallback(userDataPath, busyFallbackPort) + const configured = await reservePort() + const squatted = await reservePort() + seedFallback(userDataPath, squatted.port) + await configured.release() - const server = await startRuntime({ userDataPath, wsPort: configuredPort }) + const server = await startRuntime({ userDataPath, wsPort: configured.port }) try { - expect(servedPort(server)).toBe(configuredPort) - expect(existsSync(fallbackFile(userDataPath))).toBe(false) + expect(servedPort(server)).toBe(configured.port) + // Why: occupation is transient (an outbound ephemeral socket is enough) and a phone has no recovery + // for a dropped endpoint but re-pairing, so one bad launch must not discard a live pairing. + expect(readWsFallbackPort(userDataPath)).toBe(squatted.port) } finally { await server.stop() } - await releasePort(squatter) - const relaunched = await startRuntime({ userDataPath, wsPort: configuredPort }) + await squatted.release() + const relaunched = await startRuntime({ userDataPath, wsPort: configured.port }) try { - expect(servedPort(relaunched)).toBe(configuredPort) + // Why: STA-1511's self-heal — the pairing comes back on its own once the squatter leaves. + expect(servedPort(relaunched)).toBe(squatted.port) + expect(readWsFallbackPort(userDataPath)).toBe(squatted.port) } finally { await relaunched.stop() } }) + it('keeps a fallback whose bind failed for an unclassified reason', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const userDataPath = makeUserDataPath() + const configured = await reservePort() + const fallback = await reservePort() + seedFallback(userDataPath, fallback.port) + // Why: the transport falls through the fallback candidate on ANY error, so process-wide failures like + // descriptor exhaustion reach the same branch — they say nothing about the port and must not clear it. + restoreListen = blockListen( + (port) => port === fallback.port, + () => + Object.assign(new Error('listen EMFILE: too many open files'), { + code: 'EMFILE', + syscall: 'listen' + }) + ) + await configured.release() + await fallback.release() + + const server = await startRuntime({ userDataPath, wsPort: configured.port }) + try { + expect(servedPort(server)).toBe(configured.port) + expect(readWsFallbackPort(userDataPath)).toBe(fallback.port) + } finally { + await server.stop() + } + }) + it('keeps a bindable fallback winning over the configured port (STA-1511)', async () => { const userDataPath = makeUserDataPath() - const configuredPort = await reserveFreePort() - const fallbackPort = await reserveFreePort() - seedFallback(userDataPath, fallbackPort) + const configured = await reservePort() + const fallback = await reservePort() + seedFallback(userDataPath, fallback.port) + await configured.release() + await fallback.release() - const server = await startRuntime({ userDataPath, wsPort: configuredPort }) + const server = await startRuntime({ userDataPath, wsPort: configured.port }) try { // Why: devices paired to the fallback keep reconnecting only while it stays served and persisted. - expect(servedPort(server)).toBe(fallbackPort) - expect(readWsFallbackPort(userDataPath)).toBe(fallbackPort) + expect(servedPort(server)).toBe(fallback.port) + expect(readWsFallbackPort(userDataPath)).toBe(fallback.port) } finally { await server.stop() } @@ -196,20 +240,22 @@ describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () it('leaves an untried fallback in place when a pinned port wins (#9005)', async () => { const userDataPath = makeUserDataPath() - const pinnedPort = await reserveFreePort() - const fallbackPort = await reserveFreePort() - seedFallback(userDataPath, fallbackPort) + const pinned = await reservePort() + const fallback = await reservePort() + seedFallback(userDataPath, fallback.port) + await pinned.release() + await fallback.release() const server = await startRuntime({ userDataPath, - wsPort: pinnedPort, + wsPort: pinned.port, preferPinnedWsPort: true }) try { - expect(servedPort(server)).toBe(pinnedPort) + expect(servedPort(server)).toBe(pinned.port) // Why: pinned-first means the fallback was never attempted, so nothing proves it dead — clearing it - // here would discard a working endpoint that `orca serve` without --port still hands back to devices. - expect(readWsFallbackPort(userDataPath)).toBe(fallbackPort) + // here would discard a working endpoint that a later default launch still hands back to devices. + expect(readWsFallbackPort(userDataPath)).toBe(fallback.port) } finally { await server.stop() } @@ -218,19 +264,19 @@ describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () it('keeps the fallback when a pinned port is busy and the fallback serves (#9005)', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const userDataPath = makeUserDataPath() - const pinnedPort = await reserveFreePort() - const fallbackPort = await reserveFreePort() - await holdPort(pinnedPort) - seedFallback(userDataPath, fallbackPort) + const pinned = await reservePort() + const fallback = await reservePort() + seedFallback(userDataPath, fallback.port) + await fallback.release() const server = await startRuntime({ userDataPath, - wsPort: pinnedPort, + wsPort: pinned.port, preferPinnedWsPort: true }) try { - expect(servedPort(server)).toBe(fallbackPort) - expect(readWsFallbackPort(userDataPath)).toBe(fallbackPort) + expect(servedPort(server)).toBe(fallback.port) + expect(readWsFallbackPort(userDataPath)).toBe(fallback.port) } finally { await server.stop() } @@ -254,9 +300,9 @@ describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) const userDataPath = makeUserDataPath() - const configuredPort = await reserveFreePort() - const fallbackPort = await reserveFreePort() - const seeded = JSON.stringify({ port: fallbackPort }) + const configured = await reservePort() + const fallback = await reservePort() + const seeded = JSON.stringify({ port: fallback.port }) writeFileSync(fallbackFile(userDataPath), seeded, 'utf8') // Why: a non-listen EACCES is not a fall-through candidate error, so the configured port rethrows and // the whole WS start fails — a failed bind must never mutate what the next launch will trust. @@ -264,8 +310,10 @@ describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () () => true, () => Object.assign(new Error('open EACCES'), { code: 'EACCES', syscall: 'open' }) ) + await configured.release() + await fallback.release() - const server = await startRuntime({ userDataPath, wsPort: configuredPort }) + const server = await startRuntime({ userDataPath, wsPort: configured.port }) try { expect(server.getWebSocketEndpoint()).toBeNull() expect(readFileSync(fallbackFile(userDataPath), 'utf8')).toBe(seeded) @@ -277,21 +325,20 @@ describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () it('persists an OS-assigned port when the configured port is taken and re-binds it next launch', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const userDataPath = makeUserDataPath() - const configuredPort = await reserveFreePort() - const firstInstance = await holdPort(configuredPort) + const configured = await reservePort() - const server = await startRuntime({ userDataPath, wsPort: configuredPort }) + const server = await startRuntime({ userDataPath, wsPort: configured.port }) let assignedPort: number | null = null try { assignedPort = servedPort(server) - expect(assignedPort).not.toBe(configuredPort) + expect(assignedPort).not.toBe(configured.port) expect(readWsFallbackPort(userDataPath)).toBe(assignedPort) } finally { await server.stop() } - await releasePort(firstInstance) - const relaunched = await startRuntime({ userDataPath, wsPort: configuredPort }) + await configured.release() + const relaunched = await startRuntime({ userDataPath, wsPort: configured.port }) try { // Why: STA-1511 stability — the freed configured port must not steal the endpoint devices paired to. expect(servedPort(relaunched)).toBe(assignedPort) @@ -300,4 +347,71 @@ describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () await relaunched.stop() } }) + + it('persists the widen port when the pairing rebind cannot retake the configured port', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const userDataPath = makeUserDataPath() + const configured = await reservePort() + await configured.release() + + const server = await startRuntime({ userDataPath, wsPort: configured.port }) + try { + expect(servedPort(server)).toBe(configured.port) + expect(existsSync(fallbackFile(userDataPath))).toBe(false) + + // Why: the widen stops the loopback listener before re-binding, so a port lost in that gap lands the + // LAN listener on an OS-assigned one — which the QR already advertises and devices must find again. + restoreListen = blockListen((port) => port === configured.port, portInUse) + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(true) + + const widenedPort = servedPort(server) + expect(widenedPort).not.toBe(configured.port) + expect(readWsFallbackPort(userDataPath)).toBe(widenedPort) + } finally { + await server.stop() + } + }) + + it('leaves the store alone when a failed widen recovers on a different loopback port', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const userDataPath = makeUserDataPath() + const configured = await reservePort() + const fallback = await reservePort() + seedFallback(userDataPath, fallback.port) + await configured.release() + await fallback.release() + + const server = await startRuntime({ userDataPath, wsPort: configured.port }) + try { + expect(servedPort(server)).toBe(fallback.port) + const seeded = readFileSync(fallbackFile(userDataPath), 'utf8') + + // Why: fail the wide bind outright and make the loopback restore miss its old port, so recovery lands + // on an OS-assigned one — a degraded listener that serves no pairing. + restoreListen = blockListen( + (port, host) => host === ALL_INTERFACES || port === fallback.port, + (port, host) => + host === ALL_INTERFACES + ? Object.assign(new Error('open EACCES'), { code: 'EACCES', syscall: 'open' }) + : portInUse(port) + ) + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(false) + + // Why: persisting the recovery port would overwrite the fallback that paired devices still dial, for + // a listener that cannot serve them anyway. + expect(servedPort(server)).not.toBe(fallback.port) + expect(readFileSync(fallbackFile(userDataPath), 'utf8')).toBe(seeded) + } finally { + await server.stop() + } + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 616bbc8ff06..768705fda12 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1196,7 +1196,7 @@ export class OrcaRuntimeRpcServer { // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) - this.syncPersistedWsFallback(transport.resolvedPort) + this.syncPersistedWsFallback(transport) activeTransports.push(transport) transportsMeta.push({ kind: 'websocket', endpoint }) } catch (error) { @@ -1241,19 +1241,17 @@ export class OrcaRuntimeRpcServer { }) } - // Why: after a successful bind the persisted fallback must describe what is actually served, or the - // advertised endpoint oscillates between the two across launches (STA-4859). A resolved port that - // differs from the configured one is the STA-1511 fallback to keep. Landing ON the configured port in - // default (fallback-first) mode means any persisted fallback was tried and failed, so it is obsolete; - // in pinned mode (`serve --port`, #9005) the pin binds first and the fallback was never tried — leave it. - // wsPort 0 (E2E) stays fully exempt. - private syncPersistedWsFallback(resolvedPort: number): void { + // Why: a fallback the OS refused to listen on is dead, and keeping it re-arms the STA-4859 endpoint + // flip-flop; dropping it costs devices paired to it one re-pair. Merely-occupied or unclassified + // failures keep the file so a still-valid pairing self-heals next launch (STA-1511), and a pinned port + // (#9005) never attempts the fallback, so its success proves nothing. wsPort 0 (E2E) stays exempt. + private syncPersistedWsFallback(transport: WebSocketTransport): void { if (this.wsPort === 0) { return } - if (resolvedPort !== this.wsPort) { - writeWsFallbackPort(this.userDataPath, resolvedPort) - } else if (!this.preferPinnedWsPort) { + if (transport.resolvedPort !== this.wsPort) { + writeWsFallbackPort(this.userDataPath, transport.resolvedPort) + } else if (!this.preferPinnedWsPort && transport.persistedFallbackListenDenied) { clearWsFallbackPort(this.userDataPath) } } @@ -1434,7 +1432,7 @@ export class OrcaRuntimeRpcServer { this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint } } try { - this.syncPersistedWsFallback(widened.transport.resolvedPort) + this.syncPersistedWsFallback(widened.transport) this.writeMetadata() } catch (persistError) { // Why: the wide listener is live and tracked; a persistence failure must not tear it down. Keep @@ -1448,6 +1446,8 @@ export class OrcaRuntimeRpcServer { // Why: a failed widen already tore down the loopback listener; bring one back on the same host/port so the // runtime keeps serving locally (wsBoundHost stays loopback, so the next pairing offer retries the widen). + // Deliberately does NOT sync the fallback store: this is a degraded loopback-only listener that serves no + // pairing, so persisting its port would overwrite the fallback that paired devices still dial. private async recoverWebSocketBindAfterFailedWiden( index: number, previousPort: number