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