From b978bc4dc266432b12bd06bb0d69d77308093597 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:13:03 -0700 Subject: [PATCH] feat(wsl): connect to a WSL distro's orcad over wsl.exe stdio only (WSL-via-orcad C5) (#27834) --- src/main/ssh/orcad-stdio-bridge-provider.ts | 11 +- src/main/wsl/wsl-orcad-bridge-test-child.ts | 88 ++++++ src/main/wsl/wsl-orcad-connection.test.ts | 260 +++++++++++++++++ src/main/wsl/wsl-orcad-connection.ts | 295 ++++++++++++++++++++ src/main/wsl/wsl-orcad-deploy.ts | 7 +- src/main/wsl/wsl-orcad-stdio-bridge.test.ts | 137 +++++++++ src/main/wsl/wsl-orcad-stdio-bridge.ts | 42 +++ 7 files changed, 835 insertions(+), 5 deletions(-) create mode 100644 src/main/wsl/wsl-orcad-bridge-test-child.ts create mode 100644 src/main/wsl/wsl-orcad-connection.test.ts create mode 100644 src/main/wsl/wsl-orcad-connection.ts create mode 100644 src/main/wsl/wsl-orcad-stdio-bridge.test.ts create mode 100644 src/main/wsl/wsl-orcad-stdio-bridge.ts diff --git a/src/main/ssh/orcad-stdio-bridge-provider.ts b/src/main/ssh/orcad-stdio-bridge-provider.ts index 7bef1cee123..9f1f2930259 100644 --- a/src/main/ssh/orcad-stdio-bridge-provider.ts +++ b/src/main/ssh/orcad-stdio-bridge-provider.ts @@ -24,20 +24,22 @@ import type { StartedPortForward } from './ssh-port-forward-provider' -type OrcadStdioBridgeDependencies = { +export type OrcadStdioBridgeDependencies = { resolveBridge: (conn: OrcadHostTransport, port: number) => Promise checkBridge: (conn: OrcadHostTransport, bridge: OrcadStdioBridge) => Promise openChannel: (conn: OrcadHostTransport, bridge: OrcadStdioBridge) => Promise } -const defaultDependencies: OrcadStdioBridgeDependencies = { +export const ORCAD_STDIO_BRIDGE_DEPENDENCIES: OrcadStdioBridgeDependencies = { resolveBridge: resolveOrcadStdioBridge, checkBridge: checkOrcadStdioBridge, openChannel: openOrcadStdioBridgeChannel } export class OrcadStdioBridgePortForwardProvider implements SshPortForwardProvider { - constructor(private readonly dependencies: OrcadStdioBridgeDependencies = defaultDependencies) {} + constructor( + private readonly dependencies: OrcadStdioBridgeDependencies = ORCAD_STDIO_BRIDGE_DEPENDENCIES + ) {} // Why not system SSH: each bridge socket would be its own `ssh` process (a fresh login, and // perhaps a key prompt, when multiplexing is off), and its refusal can't be probed up front. @@ -50,7 +52,8 @@ export class OrcadStdioBridgePortForwardProvider implements SshPortForwardProvid } } -async function startOrcadStdioBridge( +/** Transport-agnostic: any orcad host whose exec can run the bridge (SSH, a WSL distro). */ +export async function startOrcadStdioBridge( host: OrcadHostTransport, options: PortForwardStartOptions, deps: OrcadStdioBridgeDependencies diff --git a/src/main/wsl/wsl-orcad-bridge-test-child.ts b/src/main/wsl/wsl-orcad-bridge-test-child.ts new file mode 100644 index 00000000000..7353eb67c1c --- /dev/null +++ b/src/main/wsl/wsl-orcad-bridge-test-child.ts @@ -0,0 +1,88 @@ +/** + * Test-only: a `wsl.exe` spawner whose children run the stdio bridge in-process. Each child dials + * the port the bridge command names on this machine and prints the real sentinel, so the listener + * and WSL transport above it run unchanged. + */ +import { EventEmitter } from 'node:events' +import { connect } from 'node:net' +import { PassThrough } from 'node:stream' +import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec' +import { + ORCAD_STDIO_BRIDGE_READY, + ORCAD_STDIO_BRIDGE_REFUSED, + orcadPosixStdioBridgeCommand +} from '../ssh/orcad-stdio-bridge-script' +import type { OrcadStdioBridge } from '../ssh/orcad-stdio-bridge' + +export type FakeWslBridgeChildren = { + spawn: PipedProcessSpawner + /** Every spawned child's guest command, in order. */ + commands: string[] + live: () => number + maxLive: () => number +} + +/** The bridge the WSL path resolves for a Linux distro, without the platform probe. */ +export function posixOrcadStdioBridge(port: number): OrcadStdioBridge { + return { command: orcadPosixStdioBridgeCommand(port), mode: 'raw', wrapCommand: true } +} + +export function fakeWslBridgeChildren(): FakeWslBridgeChildren { + const commands: string[] = [] + let live = 0 + let maxLive = 0 + const spawn: PipedProcessSpawner = (spec) => { + const command = spec.args?.at(-1) ?? '' + commands.push(command) + live += 1 + maxLive = Math.max(maxLive, live) + const exitState: { exitCode: number | null; signalCode: NodeJS.Signals | null } = { + exitCode: null, + signalCode: null + } + const child = Object.assign(new EventEmitter(), exitState, { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: () => { + socket.destroy() + finish(null, 'SIGTERM') + return true + } + }) + let finished = false + const finish = (code: number | null, signal: NodeJS.Signals | null = null): void => { + if (finished) { + return + } + finished = true + live -= 1 + child.exitCode = code + child.signalCode = signal + child.emit('exit', code, signal) + child.stdout.end() + child.stderr.end() + setImmediate(() => child.emit('close', code, signal)) + } + const port = Number(/ (\d+); fi; done;/.exec(command)?.[1] ?? 0) + const socket = connect({ host: '127.0.0.1', port }) + let connected = false + socket.on('connect', () => { + connected = true + child.stdout.write(`${ORCAD_STDIO_BRIDGE_READY}\n`) + child.stdin.pipe(socket) + socket.pipe(child.stdout, { end: false }) + }) + socket.on('error', (error: NodeJS.ErrnoException) => { + if (!connected) { + child.stdout.write(`${ORCAD_STDIO_BRIDGE_REFUSED} ${error.code ?? 'ERROR'}\n`) + } + finish(0) + }) + socket.on('close', () => finish(0)) + process.nextTick(() => child.emit('spawn')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the WSL channel reads only the streams, exit state, kill and events assigned above. + return child as unknown as PipedChildProcess + } + return { spawn, commands, live: () => live, maxLive: () => maxLive } +} diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts new file mode 100644 index 00000000000..65f01c0e132 --- /dev/null +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -0,0 +1,260 @@ +import { connect, type Server, type Socket } from 'node:net' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer, parsePairingCode, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import type { ServeReadiness } from '../server/serve-readiness' +import { OrcadManagedIdentityError } from '../ssh/orcad-managed-tunnel-identity' +import { checkOrcadStdioBridge, openOrcadStdioBridgeChannel } from '../ssh/orcad-stdio-bridge' +import { startEchoServer } from '../ssh/orcad-stdio-bridge-test-channel' +import { fakeWslBridgeChildren, posixOrcadStdioBridge } from './wsl-orcad-bridge-test-child' +import { + WslOrcadConnectionManager, + WslOrcadConnectionSupersededError, + WslOrcadLaunchDeferredError, + type WslOrcadConnection, + type WslOrcadConnectionDependencies +} from './wsl-orcad-connection' +import type { WslOrcadLaunch } from './wsl-orcad-deploy' +import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { listenWslOrcadStdioBridge } from './wsl-orcad-stdio-bridge' + +let first: { server: Server; port: number } +let second: { server: Server; port: number } +const managers: WslOrcadConnectionManager[] = [] +const clients: Socket[] = [] + +beforeAll(async () => { + first = await startEchoServer() + second = await startEchoServer() +}) + +afterEach(async () => { + for (const client of clients.splice(0)) { + client.destroy() + } + await Promise.all(managers.splice(0).map((manager) => manager.dispose())) +}) + +afterAll(() => { + first.server.close() + second.server.close() +}) + +function readiness(port: number, deviceToken = 'device-token'): ServeReadiness { + const endpoint = `ws://127.0.0.1:${port}` + return { + runtimeId: 'runtime-1', + boundEndpoint: endpoint, + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { + available: true, + url: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken, + publicKeyB64: 'public-key', + pairedDeviceId: 'device-1' + }), + endpoint, + deviceId: 'device-1', + webClientUrl: null, + scope: 'runtime', + qr: null + } + } +} + +function ready(port: number, deviceToken?: string): WslOrcadLaunch { + return { + outcome: 'ready', + distro: 'Ubuntu', + distroId: null, + port, + fullVersion: '1.0.0', + pid: 42, + readiness: readiness(port, deviceToken), + holder: 'adopted' + } +} + +function setup(overrides: Partial = {}) { + const children = fakeWslBridgeChildren() + const launches: WslOrcadLaunch[] = [] + const deployAndLaunch = vi.fn(async () => launches.shift() ?? ready(first.port)) + const onChange = vi.fn() + const manager = new WslOrcadConnectionManager({ + deployAndLaunch, + createTransport: (target) => createWslOrcadHostTransport({ target, spawn: children.spawn }), + listen: (transport, options) => + listenWslOrcadStdioBridge(transport, options, { + resolveBridge: async (_conn, port) => posixOrcadStdioBridge(port), + checkBridge: checkOrcadStdioBridge, + openChannel: openOrcadStdioBridgeChannel + }), + verifyIdentity: async () => ({ verdict: 'verified' }), + onChange, + ...overrides + }) + managers.push(manager) + return { manager, children, launches, deployAndLaunch, onChange } +} + +function dial(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1', () => resolve(socket)) + socket.once('error', reject) + clients.push(socket) + }) +} + +function echoOf(socket: Socket, message: string): Promise { + return new Promise((resolve) => { + let received = '' + socket.on('data', (chunk: Buffer) => { + received += chunk.toString('utf8') + if (received.length >= message.length) { + resolve(received) + } + }) + socket.write(message) + }) +} + +async function roundTrip(connection: WslOrcadConnection, message: string): Promise { + return echoOf(await dial(connection.localPort), message) +} + +async function until(condition: () => boolean): Promise { + for (let i = 0; i < 200 && !condition(); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(condition()).toBe(true) +} + +describe('WslOrcadConnectionManager', () => { + it('rewrites the pairing offer to the local bridge port and nothing else', async () => { + const { manager, onChange } = setup() + const connection = await manager.ensure('Ubuntu') + + expect(connection).toMatchObject({ + distro: 'Ubuntu', + environmentId: 'wsl:ubuntu', + remotePort: first.port, + fullVersion: '1.0.0' + }) + expect(connection.localPort).not.toBe(first.port) + expect(parsePairingCode(connection.pairingOffer)).toEqual({ + v: PAIRING_OFFER_VERSION, + endpoint: `ws://127.0.0.1:${connection.localPort}/`, + deviceToken: 'device-token', + publicKeyB64: 'public-key', + pairedDeviceId: 'device-1' + }) + expect(onChange).toHaveBeenCalledWith('Ubuntu', connection) + expect(await roundTrip(connection, 'hello')).toBe('hello') + }) + + it('shares one deploy and one bridge between concurrent connects to a distro', async () => { + const { manager, deployAndLaunch, children } = setup() + const [a, b] = await Promise.all([manager.ensure('Ubuntu'), manager.ensure('ubuntu ')]) + expect(a).toBe(b) + expect(deployAndLaunch).toHaveBeenCalledTimes(1) + expect(manager.list()).toEqual([a]) + expect(manager.get('UBUNTU')).toBe(a) + // Only the bridge check ran a child so far. + expect(children.commands).toHaveLength(1) + }) + + it('keeps the bridge when orcad still listens on the same port', async () => { + const { manager, children, launches, onChange } = setup() + const connection = await manager.ensure('Ubuntu') + launches.push(ready(first.port)) + expect(await manager.ensure('Ubuntu')).toBe(connection) + expect(children.commands).toHaveLength(1) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('reports a changed pairing offer without rebuilding the bridge', async () => { + const { manager, launches, onChange } = setup() + const connection = await manager.ensure('Ubuntu') + launches.push(ready(first.port, 'rotated-token')) + const refreshed = await manager.ensure('Ubuntu') + expect(refreshed.localPort).toBe(connection.localPort) + expect(parsePairingCode(refreshed.pairingOffer)?.deviceToken).toBe('rotated-token') + expect(onChange).toHaveBeenLastCalledWith('Ubuntu', refreshed) + }) + + it('moves the bridge to the new port after a relaunch, on the same local port', async () => { + const { manager, launches, children } = setup() + const before = await manager.ensure('Ubuntu') + const stale = await dial(before.localPort) + expect(await echoOf(stale, 'old')).toBe('old') + const staleClosed = new Promise((resolve) => stale.once('close', resolve)) + + launches.push(ready(second.port)) + const after = await manager.ensure('Ubuntu') + expect(after).not.toBe(before) + expect(after.remotePort).toBe(second.port) + expect(after.localPort).toBe(before.localPort) + expect(after.pairingOffer).toBe(before.pairingOffer) + await staleClosed + expect(await roundTrip(after, 'new')).toBe('new') + expect(children.commands.at(-1)).toContain(` ${second.port}; fi; done;`) + }) + + it('throws the deferral and starts no bridge', async () => { + const { manager, launches, children } = setup() + launches.push({ + outcome: 'deferred', + distro: 'Ubuntu', + candidateVersion: '2.0.0', + code: 'live_terminals', + reason: 'Terminals are running.', + forceable: true + }) + await expect(manager.ensure('Ubuntu')).rejects.toBeInstanceOf(WslOrcadLaunchDeferredError) + expect(children.commands).toHaveLength(0) + expect(manager.get('Ubuntu')).toBeNull() + }) + + it('refuses a foreign server behind the port and closes its bridge', async () => { + const { manager, children } = setup({ + verifyIdentity: async () => ({ verdict: 'foreign', detail: 'another runtime' }) + }) + await expect(manager.ensure('Ubuntu')).rejects.toBeInstanceOf(OrcadManagedIdentityError) + expect(manager.get('Ubuntu')).toBeNull() + await until(() => children.live() === 0) + }) + + it('drops a connect that a close overtook', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const { manager } = setup({ + deployAndLaunch: async () => { + await gate + return ready(first.port) + } + }) + const pending = manager.ensure('Ubuntu') + await manager.close('Ubuntu') + release() + await expect(pending).rejects.toBeInstanceOf(WslOrcadConnectionSupersededError) + expect(manager.get('Ubuntu')).toBeNull() + }) + + it('tears down every bridge child and the listener on close', async () => { + const { manager, children, onChange } = setup() + const connection = await manager.ensure('Ubuntu') + const sockets = await Promise.all([1, 2, 3].map(() => dial(connection.localPort))) + await Promise.all(sockets.map((socket, i) => echoOf(socket, `s${i}`))) + expect(children.live()).toBe(3) + + await connection.close() + await until(() => children.live() === 0) + expect(manager.get('Ubuntu')).toBeNull() + expect(onChange).toHaveBeenLastCalledWith('Ubuntu', null) + await expect(dial(connection.localPort)).rejects.toThrow() + }) +}) diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts new file mode 100644 index 00000000000..91c0fff0138 --- /dev/null +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -0,0 +1,295 @@ +/** + * The desktop's connection to one WSL distro's orcad: deploy and launch (C4), the `wsl.exe` stdio + * bridge listener, and orcad's pairing offer rewritten to that listener's loopback port. One per + * distro; a relaunch that moved orcad's port moves the bridge with it. In memory only: the + * deployment record (C7) persists what `onChange` reports. + */ +import { parsePairingCode } from '../../shared/pairing' +import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' +import type { ServeReadiness } from '../server/serve-readiness' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import { + OrcadManagedIdentityError, + verifyOrcadPairingIdentity, + type OrcadTunnelIdentity +} from '../ssh/orcad-managed-tunnel-identity' +import { tunneledOrcadPairingCode } from '../ssh/orcad-tunneled-pairing' +import type { StartedPortForward } from '../ssh/ssh-port-forward-provider' +import type { WslDistroHolder } from './wsl-distro-holder' +import { wslDistroHolderKey } from './wsl-distro-holder-record' +import { + defaultWslOrcadEnvironmentId, + deployAndLaunchWslOrcad, + type WslOrcadDeployOptions, + type WslOrcadLaunch +} from './wsl-orcad-deploy' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' +import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { + listenWslOrcadStdioBridge, + type WslOrcadStdioBridgeOptions +} from './wsl-orcad-stdio-bridge' + +export type WslOrcadConnection = { + distro: string + environmentId: string + /** The loopback port on this machine the pairing offer points at. */ + localPort: number + /** orcad's port inside the distro. */ + remotePort: number + fullVersion: string + /** orcad's pairing code, with its endpoint rewritten to `ws://127.0.0.1:`. */ + pairingOffer: string + close(): Promise +} + +type WslOrcadDeferredLaunch = Extract + +/** orcad installed an update it may not activate yet (live terminals); nothing is listening. */ +export class WslOrcadLaunchDeferredError extends Error { + constructor(readonly launch: WslOrcadDeferredLaunch) { + super(launch.reason) + this.name = 'WslOrcadLaunchDeferredError' + } +} + +export class WslOrcadConnectionSupersededError extends Error { + constructor(distro: string) { + super(`The connection to the WSL distro ${distro} was closed while it was being opened.`) + this.name = 'WslOrcadConnectionSupersededError' + } +} + +export type WslOrcadConnectOptions = { + environmentId?: string + label?: string + /** Rebinds a port a persisted pairing offer already names; 0 or absent picks a free one. */ + localPort?: number + force?: boolean + signal?: AbortSignal +} + +export type WslOrcadConnectionDependencies = { + holder?: WslDistroHolder + deployAndLaunch?: (distro: string, options: WslOrcadDeployOptions) => Promise + createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport + listen?: ( + transport: OrcadHostTransport, + options: WslOrcadStdioBridgeOptions + ) => Promise + verifyIdentity?: (pairingOffer: string, readiness: ServeReadiness) => Promise + /** A connection (re)opened or its offer changed; null once it closed. */ + onChange?: (distro: string, connection: WslOrcadConnection | null) => void +} + +type ActiveWslOrcadConnection = { + connection: WslOrcadConnection + forward: StartedPortForward +} + +export class WslOrcadConnectionManager { + private readonly active = new Map() + private readonly inFlight = new Map>() + private readonly generations = new Map() + + constructor(private readonly dependencies: WslOrcadConnectionDependencies = {}) {} + + /** Joins a connect already running for this distro rather than racing a second bridge. */ + ensure(distro: string, options: WslOrcadConnectOptions = {}): Promise { + const name = distro.trim() + if (!name) { + return Promise.reject(new Error('A WSL distro name is required')) + } + const key = wslDistroHolderKey(name) + const pending = this.inFlight.get(key) + if (pending) { + return pending + } + const operation = this.connect(name, key, options).finally(() => { + if (this.inFlight.get(key) === operation) { + this.inFlight.delete(key) + } + }) + this.inFlight.set(key, operation) + return operation + } + + get(distro: string): WslOrcadConnection | null { + return this.active.get(wslDistroHolderKey(distro))?.connection ?? null + } + + list(): WslOrcadConnection[] { + return [...this.active.values()].map((entry) => entry.connection) + } + + async close(distro: string): Promise { + const key = wslDistroHolderKey(distro) + this.generations.set(key, this.generation(key) + 1) + const entry = this.active.get(key) + if (!entry) { + return + } + this.active.delete(key) + await entry.forward.close() + this.dependencies.onChange?.(entry.connection.distro, null) + } + + async dispose(): Promise { + const keys = new Set([...this.active.keys(), ...this.inFlight.keys()]) + await Promise.all([...keys].map((key) => this.close(key))) + } + + private generation(key: string): number { + return this.generations.get(key) ?? 0 + } + + private async connect( + distro: string, + key: string, + options: WslOrcadConnectOptions + ): Promise { + const generation = this.generation(key) + const environmentId = options.environmentId ?? defaultWslOrcadEnvironmentId(distro) + const label = options.label ?? distro + const transport = (this.dependencies.createTransport ?? defaultTransport)({ + kind: 'wsl', + id: environmentId, + distro, + label + }) + // Every call relaunches a stopped orcad, so a distro restart is recovered here. + const launch = await (this.dependencies.deployAndLaunch ?? deployAndLaunchWslOrcad)(distro, { + environmentId, + label, + force: options.force, + signal: options.signal, + holder: this.dependencies.holder, + createTransport: () => transport + }) + if (launch.outcome === 'deferred') { + throw new WslOrcadLaunchDeferredError(launch) + } + if (this.generation(key) !== generation) { + throw new WslOrcadConnectionSupersededError(distro) + } + const current = this.active.get(key) + if ( + current && + current.connection.remotePort === launch.port && + current.connection.environmentId === environmentId + ) { + return this.refreshOffer(key, current, launch) + } + // orcad moved ports (or this is the first connect): the old bridge dials a dead port. + const preferredLocalPort = current?.connection.localPort ?? options.localPort ?? 0 + if (current) { + this.active.delete(key) + await current.forward.close() + this.dependencies.onChange?.(distro, null) + } + const forward = await this.listen(transport, { + environmentId, + remotePort: launch.port, + localPort: preferredLocalPort, + label: `Orca server in WSL: ${label}` + }) + try { + if (this.generation(key) !== generation) { + throw new WslOrcadConnectionSupersededError(distro) + } + const pairingOffer = tunneledOrcadPairingCode(launch.readiness, forward.entry.localPort) + const identity = await (this.dependencies.verifyIdentity ?? verifyWslOrcadIdentity)( + pairingOffer, + launch.readiness + ) + if (identity.verdict === 'foreign') { + throw new OrcadManagedIdentityError(launch.port, identity.detail) + } + if (identity.verdict === 'unreachable') { + // Why not fail: a server that is still starting is not a wrong server. + console.warn(`[wsl] Orca server in ${distro} did not answer its bridge: ${identity.detail}`) + } + if (this.generation(key) !== generation) { + throw new WslOrcadConnectionSupersededError(distro) + } + const entry: ActiveWslOrcadConnection = { + forward, + connection: { + distro, + environmentId, + localPort: forward.entry.localPort, + remotePort: launch.port, + fullVersion: launch.fullVersion, + pairingOffer, + close: () => this.closeEntry(key, entry) + } + } + this.active.set(key, entry) + this.dependencies.onChange?.(distro, entry.connection) + return entry.connection + } catch (error) { + await forward.close() + throw error + } + } + + private refreshOffer( + key: string, + entry: ActiveWslOrcadConnection, + launch: Extract + ): WslOrcadConnection { + const pairingOffer = tunneledOrcadPairingCode(launch.readiness, entry.connection.localPort) + if ( + pairingOffer === entry.connection.pairingOffer && + launch.fullVersion === entry.connection.fullVersion + ) { + return entry.connection + } + entry.connection = { ...entry.connection, pairingOffer, fullVersion: launch.fullVersion } + this.active.set(key, entry) + this.dependencies.onChange?.(entry.connection.distro, entry.connection) + return entry.connection + } + + /** A persisted local port may be taken by now; a free one beats no connection. */ + private async listen( + transport: OrcadHostTransport, + options: WslOrcadStdioBridgeOptions + ): Promise { + const listen = this.dependencies.listen ?? listenWslOrcadStdioBridge + try { + return await listen(transport, options) + } catch (error) { + if (options.localPort === 0) { + throw error + } + console.warn( + `[wsl] Could not rebind local port ${options.localPort}; picking another:`, + error instanceof Error ? error.message : error + ) + return listen(transport, { ...options, localPort: 0 }) + } + } + + private async closeEntry(key: string, entry: ActiveWslOrcadConnection): Promise { + if (this.active.get(key) === entry) { + await this.close(key) + } + } +} + +async function verifyWslOrcadIdentity( + pairingOffer: string, + readiness: ServeReadiness +): Promise { + const offer = parsePairingCode(pairingOffer) + return offer + ? verifyOrcadPairingIdentity(pairedRuntimeConnectionFromOffer(offer), { + runtimeId: readiness.runtimeId || null + }) + : { verdict: 'foreign', detail: 'The Orca server in WSL published an invalid pairing offer.' } +} + +function defaultTransport(target: WslOrcadHostTarget): OrcadHostTransport { + return createWslOrcadHostTransport({ target }) +} diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index 8b24caa04b1..27cdb045696 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -71,6 +71,11 @@ export type WslOrcadDeployOptions = { readRegistryId?: (distro: string) => Promise } +/** Until the persisted deployment record (C7) assigns one, the environment is keyed per distro. */ +export function defaultWslOrcadEnvironmentId(distro: string): string { + return `wsl:${wslDistroHolderKey(distro)}` +} + /** WSL 1 runs no Linux kernel, so the pinned Node and its pty cannot run there. */ export function isWsl1KernelRelease(release: string): boolean { return /^\d+\.\d+\.\d+-\d+-Microsoft$/.test(release.trim()) @@ -86,7 +91,7 @@ export async function deployAndLaunchWslOrcad( } const target: WslOrcadHostTarget = { kind: 'wsl', - id: options.environmentId ?? `wsl:${wslDistroHolderKey(name)}`, + id: options.environmentId ?? defaultWslOrcadEnvironmentId(name), distro: name, label: options.label ?? name } diff --git a/src/main/wsl/wsl-orcad-stdio-bridge.test.ts b/src/main/wsl/wsl-orcad-stdio-bridge.test.ts new file mode 100644 index 00000000000..70f4f15f542 --- /dev/null +++ b/src/main/wsl/wsl-orcad-stdio-bridge.test.ts @@ -0,0 +1,137 @@ +import { connect, type Server, type Socket } from 'node:net' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { checkOrcadStdioBridge, openOrcadStdioBridgeChannel } from '../ssh/orcad-stdio-bridge' +import { ORCAD_STDIO_BRIDGE_MAX_CHANNELS } from '../ssh/orcad-stdio-bridge-listener' +import { startEchoServer } from '../ssh/orcad-stdio-bridge-test-channel' +import type { StartedPortForward } from '../ssh/ssh-port-forward-provider' +import { fakeWslBridgeChildren, posixOrcadStdioBridge } from './wsl-orcad-bridge-test-child' +import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { listenWslOrcadStdioBridge } from './wsl-orcad-stdio-bridge' + +let echo: { server: Server; port: number } +const started: StartedPortForward[] = [] +const clients: Socket[] = [] + +beforeAll(async () => { + echo = await startEchoServer() +}) + +afterEach(async () => { + for (const client of clients.splice(0)) { + client.destroy() + } + await Promise.all(started.splice(0).map((forward) => forward.close())) +}) + +afterAll(() => { + echo.server.close() +}) + +async function startBridge(remotePort = echo.port) { + const children = fakeWslBridgeChildren() + const transport = createWslOrcadHostTransport({ + target: { kind: 'wsl', id: 'wsl:ubuntu', distro: 'Ubuntu', label: 'Ubuntu' }, + spawn: children.spawn + }) + const forward = await listenWslOrcadStdioBridge( + transport, + { environmentId: 'wsl:ubuntu', remotePort, localPort: 0, label: 'Ubuntu' }, + { + resolveBridge: async (_conn, port) => posixOrcadStdioBridge(port), + checkBridge: checkOrcadStdioBridge, + openChannel: openOrcadStdioBridgeChannel + } + ) + started.push(forward) + return { forward, children } +} + +function dial(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1', () => resolve(socket)) + socket.once('error', reject) + clients.push(socket) + }) +} + +function echoOf(socket: Socket, message: string): Promise { + return new Promise((resolve) => { + let received = '' + const onData = (chunk: Buffer): void => { + received += chunk.toString('utf8') + if (received.length >= message.length) { + socket.off('data', onData) + resolve(received) + } + } + socket.on('data', onData) + socket.write(message) + }) +} + +async function until(condition: () => boolean): Promise { + for (let i = 0; i < 200 && !condition(); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(condition()).toBe(true) +} + +describe('listenWslOrcadStdioBridge (real listener, fake wsl.exe echo children)', () => { + it('rides each accepted socket on its own wsl.exe bridge child', async () => { + const { forward, children } = await startBridge() + // The up-front check is one child; it has already exited. + expect(children.commands).toHaveLength(1) + await until(() => children.live() === 0) + + const first = await dial(forward.entry.localPort) + const second = await dial(forward.entry.localPort) + expect(await echoOf(first, 'one')).toBe('one') + expect(await echoOf(second, 'two')).toBe('two') + expect(children.commands).toHaveLength(3) + expect(children.live()).toBe(2) + for (const command of children.commands) { + expect(command).toContain("'.orca-remote/runtimes'/node-*/bin/node") + expect(command).toContain(` ${echo.port}; fi; done;`) + } + }) + + it(`queues past ${ORCAD_STDIO_BRIDGE_MAX_CHANNELS} sockets and reuses freed slots`, async () => { + const { forward, children } = await startBridge() + await until(() => children.live() === 0) + const count = ORCAD_STDIO_BRIDGE_MAX_CHANNELS + 4 + const sockets = await Promise.all( + Array.from({ length: count }, () => dial(forward.entry.localPort)) + ) + const first = sockets.slice(0, ORCAD_STDIO_BRIDGE_MAX_CHANNELS) + await Promise.all(first.map((socket, i) => echoOf(socket, `m${i}`))) + expect(children.live()).toBe(ORCAD_STDIO_BRIDGE_MAX_CHANNELS) + + for (const socket of first) { + socket.destroy() + } + const rest = sockets.slice(ORCAD_STDIO_BRIDGE_MAX_CHANNELS) + expect(await Promise.all(rest.map((socket, i) => echoOf(socket, `q${i}`)))).toEqual( + rest.map((_, i) => `q${i}`) + ) + expect(children.maxLive()).toBe(ORCAD_STDIO_BRIDGE_MAX_CHANNELS) + expect(children.commands).toHaveLength(count + 1) + }) + + it('drops a socket whose bridge reports a refused port', async () => { + const { forward, children } = await startBridge(1) + const socket = await dial(forward.entry.localPort) + await new Promise((resolve) => socket.once('close', resolve)) + await until(() => children.live() === 0) + }) + + it('kills every bridge child on close and stops listening', async () => { + const { forward, children } = await startBridge() + const sockets = await Promise.all([1, 2, 3].map(() => dial(forward.entry.localPort))) + await Promise.all(sockets.map((socket, i) => echoOf(socket, `c${i}`))) + expect(children.live()).toBe(3) + + await forward.close() + await until(() => children.live() === 0) + await expect(dial(forward.entry.localPort)).rejects.toThrow() + }) +}) diff --git a/src/main/wsl/wsl-orcad-stdio-bridge.ts b/src/main/wsl/wsl-orcad-stdio-bridge.ts new file mode 100644 index 00000000000..1f84f37dd1f --- /dev/null +++ b/src/main/wsl/wsl-orcad-stdio-bridge.ts @@ -0,0 +1,42 @@ +/** + * The desktop's only way into a WSL distro's orcad (design D2): a loopback listener whose every + * accepted socket rides its own `wsl.exe` child running the stdio bridge on the distro's pinned + * Node. WSL localhost forwarding is never used: it refuses connections for 0.2–1 s after a listen + * and depends on the distro's networking mode. + */ +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import { + ORCAD_STDIO_BRIDGE_DEPENDENCIES, + startOrcadStdioBridge, + type OrcadStdioBridgeDependencies +} from '../ssh/orcad-stdio-bridge-provider' +import type { StartedPortForward } from '../ssh/ssh-port-forward-provider' + +export type WslOrcadStdioBridgeOptions = { + environmentId: string + /** orcad's loopback port inside the distro. */ + remotePort: number + /** 0 picks a free one. */ + localPort: number + label: string +} + +export function listenWslOrcadStdioBridge( + transport: OrcadHostTransport, + options: WslOrcadStdioBridgeOptions, + dependencies: OrcadStdioBridgeDependencies = ORCAD_STDIO_BRIDGE_DEPENDENCIES +): Promise { + return startOrcadStdioBridge( + transport, + { + id: `wsl-orcad:${options.environmentId}`, + connectionId: options.environmentId, + localHost: '127.0.0.1', + localPort: options.localPort, + remoteHost: '127.0.0.1', + remotePort: options.remotePort, + label: options.label + }, + dependencies + ) +}