fix(mobile): clear the WS fallback only on an observed reserved-range refusal (STA-4859)

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.
This commit is contained in:
OrcaWin
2026-08-19 15:18:37 -07:00
parent 72f4d1f972
commit 52fec0b137
5 changed files with 235 additions and 103 deletions
@@ -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()
})
})
@@ -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)
}
}
+19 -7
View File
@@ -63,6 +63,7 @@ export class WebSocketTransport implements RpcTransport {
private wsClientIds = new Map<WebSocket, string>()
private heartbeatConnections = new Set<WebSocket>()
private preAuthTimers = new WeakMap<WebSocket, ReturnType<typeof setTimeout>>()
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' &&
@@ -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<number> {
const socket = createServer()
await new Promise<void>((resolve) => socket.listen(0, '127.0.0.1', () => resolve()))
const { port } = socket.address() as AddressInfo
await new Promise<void>((resolve) => socket.close(() => resolve()))
return port
}
type ReservedPort = { port: number; release: () => Promise<void> }
async function holdPort(port: number): Promise<Server> {
// 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<ReservedPort> {
const socket = createServer()
heldSockets.push(socket)
await new Promise<void>((resolve, reject) => {
socket.once('error', reject)
socket.listen(port, '127.0.0.1', () => resolve())
})
return socket
await new Promise<void>((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<void>((resolve) => socket.close(() => resolve()))
}
}
}
async function releasePort(socket: Server): Promise<void> {
heldSockets.splice(heldSockets.indexOf(socket), 1)
await new Promise<void>((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<void>
host: string
}
const original = prototype.tryListen
prototype.tryListen = function (port: number): Promise<void> {
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()
}
})
})
+12 -12
View File
@@ -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