fix(mobile): converge the persisted WS fallback port after the default port recovers (STA-4859)

The mobile WS fallback file was only ever written (when the resolved port
differed from the configured one) and never cleared. When a persisted
fallback F failed to bind — a Windows Hyper-V excluded range yields
EACCES/listen, or another process holds it with EADDRINUSE — and the
configured port D bound instead, the file still named F. On a later launch
with F bindable again, fallback-first order (STA-1511) re-took F, so the
advertised endpoint flip-flopped between F and D and stranded paired devices.

Persisted state now converges on what is actually served: one
syncPersistedWsFallback() at both bind sites writes the resolved port when it
differs from the configured one, and clears the store when the configured port
is what bound in default fallback-first mode (proof the fallback was tried and
failed, or is absent/equal).

STA-1511 is preserved — a bindable fallback still wins and stays persisted.
#9005 is preserved — under preferPinnedWsPort the pin binds first, so an
untried fallback is left alone. wsPort 0 (E2E) remains fully exempt, and a
failed WS start still never mutates the store.
This commit is contained in:
OrcaWin
2026-08-19 14:44:13 -07:00
parent 9b8dbd8930
commit 72f4d1f972
4 changed files with 361 additions and 12 deletions
@@ -1,8 +1,12 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
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 { readWsFallbackPort, writeWsFallbackPort } from './ws-fallback-port-store'
import {
clearWsFallbackPort,
readWsFallbackPort,
writeWsFallbackPort
} from './ws-fallback-port-store'
function makeUserDataPath(): string {
return mkdtempSync(join(tmpdir(), 'ws-fallback-port-test-'))
@@ -32,4 +36,20 @@ describe('ws-fallback-port-store', () => {
writeWsFallbackPort(userDataPath, 70000)
expect(readWsFallbackPort(userDataPath)).toBeUndefined()
})
it('clears a persisted fallback port', () => {
const userDataPath = makeUserDataPath()
writeWsFallbackPort(userDataPath, 54321)
clearWsFallbackPort(userDataPath)
expect(existsSync(join(userDataPath, 'mobile-ws-fallback-port.json'))).toBe(false)
expect(readWsFallbackPort(userDataPath)).toBeUndefined()
})
it('clearing is a no-op when nothing is persisted and survives an unremovable path', () => {
const userDataPath = makeUserDataPath()
expect(() => clearWsFallbackPort(userDataPath)).not.toThrow()
// Why: a directory at the file's path makes rmSync throw — clearing is best-effort like the write.
mkdirSync(join(userDataPath, 'mobile-ws-fallback-port.json'))
expect(() => clearWsFallbackPort(userDataPath)).not.toThrow()
})
})
+12 -1
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
// Why: when the preferred WS port is taken (second Orca instance), the OS
@@ -43,3 +43,14 @@ export function writeWsFallbackPort(userDataPath: string, port: number): void {
// break transport startup; the cost is a re-pair after the next restart.
}
}
// 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).
export function clearWsFallbackPort(userDataPath: string): void {
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.
}
}
@@ -0,0 +1,303 @@
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { createServer, type AddressInfo, type Server } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import { WebSocketTransport } from './rpc/ws-transport'
import { readWsFallbackPort } from './rpc/ws-fallback-port-store'
vi.mock('../git/worktree', () => {
const worktrees = [
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
]
return {
listWorktrees: vi.fn().mockResolvedValue(worktrees),
listWorktreesStrict: vi.fn().mockResolvedValue(worktrees)
}
})
const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json'
const heldSockets: Server[] = []
let restoreListen: (() => void) | null = null
afterEach(async () => {
restoreListen?.()
restoreListen = null
await Promise.all(
heldSockets
.splice(0)
.map((socket) => new Promise<void>((resolve) => socket.close(() => resolve())))
)
vi.restoreAllMocks()
})
function makeUserDataPath(): string {
return mkdtempSync(join(tmpdir(), 'orca-ws-fallback-'))
}
function fallbackFile(userDataPath: string): string {
return join(userDataPath, FALLBACK_PORT_FILE)
}
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
}
async function holdPort(port: number): Promise<Server> {
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
}
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.
function blockListen(
isBlocked: (port: number) => boolean,
makeError: (port: number) => Error
): () => void {
const prototype = WebSocketTransport.prototype as unknown as {
tryListen: (port: number) => Promise<void>
}
const original = prototype.tryListen
prototype.tryListen = function (port: number): Promise<void> {
return isBlocked(port) ? Promise.reject(makeError(port)) : original.call(this, port)
}
return () => {
prototype.tryListen = original
}
}
function listenDenied(port: number): Error {
return Object.assign(new Error(`listen EACCES: permission denied 127.0.0.1:${port}`), {
code: 'EACCES',
syscall: 'listen',
port
})
}
async function startRuntime(options: {
userDataPath: string
wsPort: number
preferPinnedWsPort?: boolean
}): Promise<OrcaRuntimeRpcServer> {
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath: options.userDataPath,
enableWebSocket: true,
wsPort: options.wsPort,
preferPinnedWsPort: options.preferPinnedWsPort
})
await server.start()
return server
}
function servedPort(server: OrcaRuntimeRpcServer): number | null {
const endpoint = server.getWebSocketEndpoint()
return endpoint === null ? null : Number(new URL(endpoint).port)
}
describe('OrcaRuntimeRpcServer persisted WS fallback convergence (STA-4859)', () => {
it('drops a fallback that failed to bind while 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 server = await startRuntime({ userDataPath, wsPort: configuredPort })
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(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.
restoreListen()
restoreListen = null
const relaunched = await startRuntime({ userDataPath, wsPort: configuredPort })
try {
expect(servedPort(relaunched)).toBe(configuredPort)
expect(existsSync(fallbackFile(userDataPath))).toBe(false)
} finally {
await relaunched.stop()
}
})
it('drops a fallback occupied by another process once the configured port serves', 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 server = await startRuntime({ userDataPath, wsPort: configuredPort })
try {
expect(servedPort(server)).toBe(configuredPort)
expect(existsSync(fallbackFile(userDataPath))).toBe(false)
} finally {
await server.stop()
}
await releasePort(squatter)
const relaunched = await startRuntime({ userDataPath, wsPort: configuredPort })
try {
expect(servedPort(relaunched)).toBe(configuredPort)
} finally {
await relaunched.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 server = await startRuntime({ userDataPath, wsPort: configuredPort })
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)
} finally {
await server.stop()
}
})
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 server = await startRuntime({
userDataPath,
wsPort: pinnedPort,
preferPinnedWsPort: true
})
try {
expect(servedPort(server)).toBe(pinnedPort)
// 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)
} finally {
await server.stop()
}
})
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 server = await startRuntime({
userDataPath,
wsPort: pinnedPort,
preferPinnedWsPort: true
})
try {
expect(servedPort(server)).toBe(fallbackPort)
expect(readWsFallbackPort(userDataPath)).toBe(fallbackPort)
} finally {
await server.stop()
}
})
it('never touches the store when the port is OS-assigned (wsPort 0, E2E)', async () => {
const userDataPath = makeUserDataPath()
const seeded = '{"port":54321}'
writeFileSync(fallbackFile(userDataPath), seeded, 'utf8')
const server = await startRuntime({ userDataPath, wsPort: 0 })
try {
expect(servedPort(server)).not.toBe(0)
expect(readFileSync(fallbackFile(userDataPath), 'utf8')).toBe(seeded)
} finally {
await server.stop()
}
})
it('leaves the store untouched when the WebSocket transport fails to start', async () => {
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 })
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.
restoreListen = blockListen(
() => true,
() => Object.assign(new Error('open EACCES'), { code: 'EACCES', syscall: 'open' })
)
const server = await startRuntime({ userDataPath, wsPort: configuredPort })
try {
expect(server.getWebSocketEndpoint()).toBeNull()
expect(readFileSync(fallbackFile(userDataPath), 'utf8')).toBe(seeded)
} finally {
await server.stop()
}
})
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 server = await startRuntime({ userDataPath, wsPort: configuredPort })
let assignedPort: number | null = null
try {
assignedPort = servedPort(server)
expect(assignedPort).not.toBe(configuredPort)
expect(readWsFallbackPort(userDataPath)).toBe(assignedPort)
} finally {
await server.stop()
}
await releasePort(firstInstance)
const relaunched = await startRuntime({ userDataPath, wsPort: configuredPort })
try {
// Why: STA-1511 stability — the freed configured port must not steal the endpoint devices paired to.
expect(servedPort(relaunched)).toBe(assignedPort)
expect(readWsFallbackPort(userDataPath)).toBe(assignedPort)
} finally {
await relaunched.stop()
}
})
})
+24 -9
View File
@@ -19,7 +19,11 @@ import { fingerprintAuthenticatedPairingCredential } from './rpc/orchestration-m
import type { RpcMessageContext, RpcTransport } from './rpc/transport'
import { UnixSocketTransport } from './rpc/unix-socket-transport'
import { WebSocketTransport } from './rpc/ws-transport'
import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-store'
import {
clearWsFallbackPort,
readWsFallbackPort,
writeWsFallbackPort
} from './rpc/ws-fallback-port-store'
import type { WebSocket } from 'ws'
import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry'
import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair'
@@ -1192,9 +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) } : {})
})
if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) {
writeWsFallbackPort(this.userDataPath, transport.resolvedPort)
}
this.syncPersistedWsFallback(transport.resolvedPort)
activeTransports.push(transport)
transportsMeta.push({ kind: 'websocket', endpoint })
} catch (error) {
@@ -1239,6 +1241,23 @@ 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 {
if (this.wsPort === 0) {
return
}
if (resolvedPort !== this.wsPort) {
writeWsFallbackPort(this.userDataPath, resolvedPort)
} else if (!this.preferPinnedWsPort) {
clearWsFallbackPort(this.userDataPath)
}
}
// Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user
// explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide.
// A grant minted for "This computer only" is excluded: its client is a browser on this machine, so
@@ -1415,11 +1434,7 @@ export class OrcaRuntimeRpcServer {
this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint }
}
try {
// Why: a rebind that lands on a different port (same-port bind refused) must be persisted so a
// later reconnect from a device paired to this port matches on the next launch (STA-1511).
if (this.wsPort !== 0 && widened.transport.resolvedPort !== this.wsPort) {
writeWsFallbackPort(this.userDataPath, widened.transport.resolvedPort)
}
this.syncPersistedWsFallback(widened.transport.resolvedPort)
this.writeMetadata()
} catch (persistError) {
// Why: the wide listener is live and tracked; a persistence failure must not tear it down. Keep