From 72f4d1f9729e3ce7906ec998f6b2d76655802a0d Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:44:13 -0700 Subject: [PATCH] fix(mobile): converge the persisted WS fallback port after the default port recovers (STA-4859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../rpc/ws-fallback-port-store.test.ts | 24 +- .../runtime/rpc/ws-fallback-port-store.ts | 13 +- ...untime-rpc-ws-fallback-convergence.test.ts | 303 ++++++++++++++++++ src/main/runtime/runtime-rpc.ts | 33 +- 4 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts 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 2edd314c212..c2c844d53e7 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.test.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.test.ts @@ -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() + }) }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index beedcea8cf2..7154c8a25a8 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -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. + } +} diff --git a/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts b/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts new file mode 100644 index 00000000000..1df84663238 --- /dev/null +++ b/src/main/runtime/runtime-rpc-ws-fallback-convergence.test.ts @@ -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((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 { + 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 +} + +async function holdPort(port: number): 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 +} + +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. +function blockListen( + isBlocked: (port: number) => boolean, + makeError: (port: number) => Error +): () => void { + const prototype = WebSocketTransport.prototype as unknown as { + tryListen: (port: number) => Promise + } + const original = prototype.tryListen + prototype.tryListen = function (port: number): Promise { + 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 { + 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() + } + }) +}) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 3759ac356c1..616bbc8ff06 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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