mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user