diff --git a/src/main/orcad/launcher.ts b/src/main/orcad/launcher.ts index 747f30cc784..bb956c00737 100644 --- a/src/main/orcad/launcher.ts +++ b/src/main/orcad/launcher.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path' import { ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' import { assertOrcadServerRuntime, handoffToBundledOrcad } from './orcad-bundled-runtime' import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' +import { enableManagedOrcadCompileCache } from './orcad-compile-cache' declare const ORCAD_SERVER_SHA256: string @@ -18,7 +19,10 @@ try { if (createHash('sha256').update(readFileSync(server)).digest('hex') !== ORCAD_SERVER_SHA256) { throw new Error('The Orca server does not match its launcher') } - loadServer = () => createRequire(entry)(server) + loadServer = () => { + enableManagedOrcadCompileCache(dirname(entry)) + createRequire(entry)(server) + } } } catch (error) { console.error('orcad: failed to launch:', error) diff --git a/src/main/orcad/orcad-compile-cache.test.ts b/src/main/orcad/orcad-compile-cache.test.ts new file mode 100644 index 00000000000..bc1c211eac2 --- /dev/null +++ b/src/main/orcad/orcad-compile-cache.test.ts @@ -0,0 +1,53 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-managed-launch-env' +import { + ORCAD_COMPILE_CACHE_DIRNAME, + enableManagedOrcadCompileCache, + orcadCompileCacheEnv +} from './orcad-compile-cache' + +const managed = { + [ORCAD_MANAGED_ACTIVATION_ROOT_ENV]: '/home/u/.orca-remote/.fence' +} + +describe('managed orcad compile cache', () => { + it('caches inside a managed slot, where slot GC removes it with the slot', () => { + const enableCompileCache = vi.fn() + enableManagedOrcadCompileCache('/home/u/.orca-remote/orcad-1+ab', managed, { + enableCompileCache + }) + expect(enableCompileCache).toHaveBeenCalledWith( + join('/home/u/.orca-remote/orcad-1+ab', ORCAD_COMPILE_CACHE_DIRNAME) + ) + }) + + it('never writes into a slot no client manages, such as a packaged app', () => { + const enableCompileCache = vi.fn() + enableManagedOrcadCompileCache('/Applications/Orca.app/orcad', {}, { enableCompileCache }) + expect(enableCompileCache).not.toHaveBeenCalled() + }) + + it('starts as before on a runtime without the API or with an unwritable slot', () => { + expect(() => enableManagedOrcadCompileCache('/slot', managed, {})).not.toThrow() + const enableCompileCache = vi.fn(() => { + throw new Error('EACCES') + }) + expect(() => + enableManagedOrcadCompileCache('/slot', managed, { enableCompileCache }) + ).not.toThrow() + }) + + it('hands the preflight child the slot cache only when this process enabled one', () => { + const enabled = { getCompileCacheDir: () => '/slot/x/v24' } + expect(orcadCompileCacheEnv('/slot', enabled, managed)).toEqual({ + NODE_COMPILE_CACHE: join('/slot', ORCAD_COMPILE_CACHE_DIRNAME) + }) + expect(orcadCompileCacheEnv('/slot', { getCompileCacheDir: () => undefined }, managed)).toEqual( + {} + ) + expect(orcadCompileCacheEnv('/slot', {}, managed)).toEqual({}) + // Regression: an unmanaged orcad's own NODE_COMPILE_CACHE stays where the operator put it. + expect(orcadCompileCacheEnv('/slot', enabled, {})).toEqual({}) + }) +}) diff --git a/src/main/orcad/orcad-compile-cache.ts b/src/main/orcad/orcad-compile-cache.ts new file mode 100644 index 00000000000..720f1d68b9e --- /dev/null +++ b/src/main/orcad/orcad-compile-cache.ts @@ -0,0 +1,51 @@ +/** + * V8 code cache for a managed slot's bundles. MEASURED on a low-spec WSL host: compiling + * orcad-server.js took ~600 ms per start, twice (server and its profile preflight); cached ~290 ms. + */ +import module from 'node:module' +import { join } from 'node:path' +import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-managed-launch-env' + +export const ORCAD_COMPILE_CACHE_DIRNAME = '.orcad-compile-cache' + +type CompileCacheModule = { + enableCompileCache?: (directory: string) => unknown + getCompileCacheDir?: () => string | undefined +} + +/** + * Only a client-managed slot (SSH or WSL), which slot GC deletes with its cache; never a packaged + * app directory, where a new file would break the bundle's signature. Never throws: an unwritable + * slot or a runtime without the API just compiles as before. + */ +export function enableManagedOrcadCompileCache( + slotDirectory: string, + env: NodeJS.ProcessEnv = process.env, + api: CompileCacheModule = module +): void { + if (!env[ORCAD_MANAGED_ACTIVATION_ROOT_ENV] || typeof api.enableCompileCache !== 'function') { + return + } + try { + api.enableCompileCache(join(slotDirectory, ORCAD_COMPILE_CACHE_DIRNAME)) + } catch { + // Best effort by design. + } +} + +/** + * Env that gives a disposable orcad child the same cache. Not for children that spawn user + * processes: an inherited NODE_COMPILE_CACHE would reach every Node program in a terminal. + */ +export function orcadCompileCacheEnv( + slotDirectory: string, + api: CompileCacheModule = module, + env: NodeJS.ProcessEnv = process.env +): Record { + // An unmanaged orcad may have an operator's NODE_COMPILE_CACHE; never redirect it into the slot. + const enabled = + Boolean(env[ORCAD_MANAGED_ACTIVATION_ROOT_ENV]) && + typeof api.getCompileCacheDir === 'function' && + Boolean(api.getCompileCacheDir()) + return enabled ? { NODE_COMPILE_CACHE: join(slotDirectory, ORCAD_COMPILE_CACHE_DIRNAME) } : {} +} diff --git a/src/main/orcad/orcad-launcher-isolation.integration.test.ts b/src/main/orcad/orcad-launcher-isolation.integration.test.ts index 464c8f0b317..af69047ef94 100644 --- a/src/main/orcad/orcad-launcher-isolation.integration.test.ts +++ b/src/main/orcad/orcad-launcher-isolation.integration.test.ts @@ -73,6 +73,7 @@ it('keeps the server and profile-state graph outside the compatibility launcher' 'src/main/orcad/orcad-app-paths.ts', 'src/main/orcad/orcad-configuration-exit.ts', 'src/main/orcad/orcad-bundled-runtime.ts', + 'src/main/orcad/orcad-compile-cache.ts', 'src/main/orcad/launcher.ts' ]) expect(inputs.some((path) => path.startsWith('node_modules/'))).toBe(false) diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index 4a3be34b44b..e72fb536584 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -16,6 +16,7 @@ import { ORCAD_SERVER_ENTRY_FILENAME, ORCAD_VERSION_FILENAME } from '../../share import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' import { runProcess } from '@orca/process-host' import { preflightOrcadNativeRuntime } from './orcad-runtime-native-preflight' +import { orcadCompileCacheEnv } from './orcad-compile-cache' import { isRunningAsBundledOrcadRuntime, OrcadBundledRuntimeError, @@ -34,7 +35,8 @@ export async function preflightBundledOrcadStartup(): Promise { const result = await runProcess({ program: process.execPath, args: [join(directory, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + // The preflight compiles the same bundle again, so it shares the server's code cache. + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...orcadCompileCacheEnv(directory) }, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, maxOutputBytes: 64 * 1024, terminationBarrier: true diff --git a/src/main/ssh/remote-install-transport.ts b/src/main/ssh/remote-install-transport.ts index b0887083c40..04091cdda61 100644 --- a/src/main/ssh/remote-install-transport.ts +++ b/src/main/ssh/remote-install-transport.ts @@ -17,6 +17,11 @@ export type RemoteCommandChannel = Duplex & { /** What remote install, lock, GC and host-probe code uses from a host connection. */ export type RemoteInstallTransport = { exec(command: string, options?: SshExecOptions): Promise + /** + * Optional: `exec` for a command whose stdin the caller never writes, which may run with stdin + * at EOF. Lets a transport whose every exec is a process start reuse one (WSL); SSH has none. + */ + execWithClosedStdin?(command: string, options?: SshExecOptions): Promise usesSystemSshTransport(): boolean /** Changes whenever the underlying transport is replaced; scopes per-transport verdicts. */ getConnectGeneration(): number diff --git a/src/main/ssh/ssh-relay-exec-closed-stdin.test.ts b/src/main/ssh/ssh-relay-exec-closed-stdin.test.ts new file mode 100644 index 00000000000..b2b7ee77668 --- /dev/null +++ b/src/main/ssh/ssh-relay-exec-closed-stdin.test.ts @@ -0,0 +1,60 @@ +import { Duplex, PassThrough, Readable } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { RemoteCommandChannel } from './remote-install-transport' +import { execCommand } from './ssh-relay-exec-command' + +function answering(output: string): RemoteCommandChannel { + const channel = new Duplex({ + emitClose: false, + read() {}, + write: (_c, _e, done) => done() + }) + Object.assign(channel, { + stdin: new PassThrough(), + stderr: new PassThrough(), + close() {} + }) + setImmediate(() => { + channel.push(output) + channel.push(null) + channel.emit('close', 0) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stdin, stderr and close were assigned above. + return channel as unknown as RemoteCommandChannel +} + +describe('execCommand transport routing', () => { + it('sends SSH the same command and options as before, through exec', async () => { + const exec = vi.fn(async (_command: string, _options?: unknown) => answering('ok')) + const conn = { exec, usesSystemSshTransport: () => false } + const signal = new AbortController().signal + await execCommand(conn, 'uname -sm', { signal }) + expect(exec).toHaveBeenCalledTimes(1) + const [command, options] = exec.mock.calls[0] ?? ['', null] + expect(options).toEqual({ signal }) + expect(command.split('\n').slice(1, -3)).toEqual(['uname -sm']) + await execCommand(conn, 'true', { wrapCommand: false }) + expect(exec).toHaveBeenLastCalledWith('true', { wrapCommand: false }) + }) + + it('uses a closed-stdin exec only when the caller has no stdin to send', async () => { + const exec = vi.fn(async () => answering('direct')) + const execWithClosedStdin = vi.fn(async () => answering('pooled')) + const conn = { + exec, + execWithClosedStdin, + usesSystemSshTransport: () => false + } + await expect(execCommand(conn, 'probe', { wrapCommand: false })).resolves.toBe('pooled') + expect(execWithClosedStdin).toHaveBeenCalledWith('probe', { + wrapCommand: false + }) + await expect( + execCommand(conn, 'cat', { + wrapCommand: false, + stdin: Readable.from(['x']) + }) + ).resolves.toBe('direct') + expect(exec).toHaveBeenCalledWith('cat', { wrapCommand: false }) + }) +}) diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index b5bd6b12a95..56c17332854 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -73,7 +73,7 @@ export function sshCommandExitError( } export async function execCommand( - conn: Pick, + conn: Pick, command: string, options?: ExecCommandOptions ): Promise { @@ -91,7 +91,11 @@ export async function execCommand( const readPayload = (stdout: string): string => fence?.readStdout(stdout) ?? stdout let channel: RemoteCommandChannel try { - channel = await conn.exec(fence?.command ?? command, execOptions) + // Why only without `stdin`: nothing below writes or ends the channel's stdin otherwise. + channel = + !stdin && conn.execWithClosedStdin + ? await conn.execWithClosedStdin(fence?.command ?? command, execOptions) + : await conn.exec(fence?.command ?? command, execOptions) } catch (error) { // Preserve identity/classifier fields while removing install-owner tokens. redactRelayInstallMarkerError(error) diff --git a/src/main/wsl/wsl-command-loop-pool.ts b/src/main/wsl/wsl-command-loop-pool.ts new file mode 100644 index 00000000000..6899648e214 --- /dev/null +++ b/src/main/wsl/wsl-command-loop-pool.ts @@ -0,0 +1,143 @@ +import type { PipedChildProcess } from '@orca/process-host/process-spec' +import type { RemoteCommandChannel } from '../ssh/remote-install-transport' +import { createSshOperationAbortError } from '../ssh/ssh-connection-utils' +import { startWslCommandLoop, wslCommandLoopMarker, type WslCommandLoop } from './wsl-command-loop' + +export type WslCommandLoopPool = { + /** A channel on an idle loop, or null when the caller should start its own wsl.exe. */ + run(command: string, signal?: AbortSignal): Promise + dispose(): void +} + +type WslCommandLoopPoolOptions = { + /** Starts `wsl.exe --exec sh -c `. */ + spawnLoop: (marker: string) => PipedChildProcess + /** Concurrent commands beyond this run on their own wsl.exe, as before. */ + maxLoops?: number + idleMs?: number + /** A guest that cannot answer the self-test in time falls back to one wsl.exe per command. */ + readyTimeoutMs?: number +} + +export function createWslCommandLoopPool(options: WslCommandLoopPoolOptions): WslCommandLoopPool { + const maxLoops = options.maxLoops ?? 4 + const idleMs = options.idleMs ?? 5_000 + const readyTimeoutMs = options.readyTimeoutMs ?? 15_000 + const loops = new Set() + const reserved = new Set() + const idleTimers = new Map>() + // Sticky: a guest without mktemp/base64, or a wsl.exe that cannot keep a loop, stays direct. + let unavailable = false + let disposed = false + + const retire = (loop: WslCommandLoop): void => { + clearTimeout(idleTimers.get(loop)) + idleTimers.delete(loop) + loops.delete(loop) + reserved.delete(loop) + loop.end() + } + + const create = (): WslCommandLoop => { + const marker = wslCommandLoopMarker() + const loop = startWslCommandLoop(options.spawnLoop(marker), marker) + loops.add(loop) + loop.onIdle(() => { + if (disposed) { + retire(loop) + return + } + const timer = setTimeout(() => retire(loop), idleMs) + timer.unref?.() + idleTimers.set(loop, timer) + }) + return loop + } + + const reserve = (): WslCommandLoop | null => { + for (const loop of loops) { + if (!loop.alive) { + retire(loop) + } else if (!loop.busy && !reserved.has(loop)) { + clearTimeout(idleTimers.get(loop)) + idleTimers.delete(loop) + reserved.add(loop) + return loop + } + } + if (loops.size >= maxLoops) { + return null + } + let loop: WslCommandLoop + try { + loop = create() + } catch { + // The caller's own wsl.exe start reports the real failure. + unavailable = true + return null + } + reserved.add(loop) + return loop + } + + return { + async run(command, signal) { + if (unavailable || disposed) { + return null + } + if (signal?.aborted) { + throw createSshOperationAbortError() + } + const loop = reserve() + if (!loop) { + return null + } + try { + await waitReady(loop, readyTimeoutMs, signal) + } catch { + retire(loop) + loop.kill() + if (signal?.aborted) { + throw createSshOperationAbortError() + } + unavailable = true + return null + } + reserved.delete(loop) + if (!loop.alive || loop.busy) { + retire(loop) + return null + } + return loop.run(command) + }, + dispose() { + disposed = true + for (const loop of loops) { + if (!loop.busy) { + retire(loop) + } + } + } + } +} + +function waitReady(loop: WslCommandLoop, timeoutMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => finish(new Error('The WSL command loop did not start')), + timeoutMs + ) + const onAbort = (): void => finish(createSshOperationAbortError()) + const finish = (error?: Error): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + if (error) { + reject(error) + } else { + resolve() + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + loop.ready.then(() => finish(), finish) + }) +} diff --git a/src/main/wsl/wsl-command-loop.test.ts b/src/main/wsl/wsl-command-loop.test.ts new file mode 100644 index 00000000000..ecdce9dbcbe --- /dev/null +++ b/src/main/wsl/wsl-command-loop.test.ts @@ -0,0 +1,135 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '@orca/process-host' +import type { PipedChildProcess } from '@orca/process-host/process-spec' +import { execCommand } from '../ssh/ssh-relay-exec-command' +import { WSL_COMMAND_LOOP_ARGV0, WSL_COMMAND_LOOP_SCRIPT } from './wsl-command-loop' +import { createWslCommandLoopPool, type WslCommandLoopPool } from './wsl-command-loop-pool' + +// The guest side is plain POSIX sh, so a local sh stands in for `wsl.exe --exec sh`. +const posix = process.platform !== 'win32' +const spawned: PipedChildProcess[] = [] +const pools: WslCommandLoopPool[] = [] + +function pool(options: { maxLoops?: number; script?: string } = {}) { + const created = createWslCommandLoopPool({ + maxLoops: options.maxLoops, + spawnLoop: (marker) => { + const child = spawnProcess({ + program: 'sh', + args: ['-c', options.script ?? WSL_COMMAND_LOOP_SCRIPT, WSL_COMMAND_LOOP_ARGV0, marker] + }) + spawned.push(child) + return child + } + }) + pools.push(created) + const conn = { + exec: () => Promise.reject(new Error('direct exec')), + execWithClosedStdin: async (command: string, options?: { signal?: AbortSignal }) => { + const channel = await created.run(command, options?.signal) + if (!channel) { + throw new Error('loop unavailable') + } + return channel + }, + usesSystemSshTransport: () => false + } + return { pool: created, conn } +} + +afterEach(() => { + pools.splice(0).forEach((entry) => entry.dispose()) + spawned.splice(0).forEach((child) => child.kill()) +}) + +describe.runIf(posix)('WSL command loop', () => { + it('runs many commands over one process with their own stdout, stderr and status', async () => { + const { conn } = pool() + expect(await execCommand(conn, 'echo one')).toBe('one\n') + expect(await execCommand(conn, "printf 'a\\nb'")).toBe('a\nb') + let stderr = '' + expect( + await execCommand(conn, 'echo out; echo err >&2', { + onStderr: (text) => (stderr = text) + }) + ).toBe('out\n') + expect(stderr).toBe('err\n') + await expect(execCommand(conn, 'echo partial; exit 7')).rejects.toMatchObject({ + exitCode: 7, + stdout: 'partial\n' + }) + expect(spawned).toHaveLength(1) + }) + + it('keeps binary output, quoting and multi-line scripts intact', async () => { + const { conn } = pool() + const script = "x='it''s'\nfor i in 1 2; do printf '%s-%s\\n' \"$x\" $i; done" + expect(await execCommand(conn, script)).toBe('its-1\nits-2\n') + expect(await execCommand(conn, 'printf "\\0\\377"')).toBe( + Buffer.from([0, 0xff]).toString('utf-8') + ) + }) + + it('gives each command EOF on stdin, its own session and no loop variables', async () => { + const { conn } = pool() + expect(await execCommand(conn, 'cat; echo eof')).toBe('eof\n') + expect(await execCommand(conn, 'echo "[${m:-}${d:-}${l:-}]"')).toBe('[]\n') + const sessionLeads = await execCommand( + conn, + 'if command -v setsid >/dev/null 2>&1; then [ "$(ps -o sid= -p $$ | tr -d " ")" = "$$" ] && echo own || echo shared; else echo own; fi' + ) + expect(sessionLeads).toBe('own\n') + }) + + it('runs concurrent commands on separate loops up to the cap, then declines', async () => { + const { pool: loops, conn } = pool({ maxLoops: 2 }) + const slow = [execCommand(conn, 'sleep 0.3; echo a'), execCommand(conn, 'sleep 0.3; echo b')] + await new Promise((resolve) => setTimeout(resolve, 50)) + await expect(loops.run('echo c')).resolves.toBeNull() + expect(await Promise.all(slow)).toEqual(['a\n', 'b\n']) + expect(await execCommand(conn, 'echo d')).toBe('d\n') + expect(spawned).toHaveLength(2) + }) + + it('does not let a backgrounded writer leak into the next command', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-loop-')) + try { + const { conn } = pool() + await execCommand(conn, `(sleep 0.2; echo late; echo done > '${dir}/f') &`) + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(readFileSync(join(dir, 'f'), 'utf8')).toBe('done\n') + expect(await execCommand(conn, 'echo next')).toBe('next\n') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('kills the loop when a command is aborted, and later commands start a new one', async () => { + const { conn } = pool() + const controller = new AbortController() + const running = execCommand(conn, 'sleep 30', { + signal: controller.signal + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + controller.abort() + await expect(running).rejects.toMatchObject({ name: 'AbortError' }) + expect(await execCommand(conn, 'echo again')).toBe('again\n') + expect(spawned).toHaveLength(2) + }) + + it('declines for good when the guest loop cannot start', async () => { + const { pool: loops } = pool({ script: 'exit 98' }) + await expect(loops.run('echo x')).resolves.toBeNull() + await expect(loops.run('echo y')).resolves.toBeNull() + expect(spawned).toHaveLength(1) + }) + + it('reports a loop that dies mid-command as a failed command', async () => { + const { conn } = pool() + await expect(execCommand(conn, 'kill -9 $PPID 2>/dev/null; sleep 5')).rejects.toThrow() + expect(await execCommand(conn, 'echo recovered')).toBe('recovered\n') + }) +}) diff --git a/src/main/wsl/wsl-command-loop.ts b/src/main/wsl/wsl-command-loop.ts new file mode 100644 index 00000000000..9f50264efa8 --- /dev/null +++ b/src/main/wsl/wsl-command-loop.ts @@ -0,0 +1,233 @@ +/** + * One long-lived `wsl.exe --exec sh` that runs stdin-free commands one after another, so a run of + * short probes pays one wsl.exe start instead of one each (MEASURED ~180 ms each on win-lowspec). + * Each command runs as `sh -c` in its own session with stdin at /dev/null; its stdout, stderr and + * exit status come back base64-framed between per-loop markers. + */ +import { randomUUID } from 'node:crypto' +import { Duplex, PassThrough, Writable } from 'node:stream' +import type { PipedChildProcess } from '@orca/process-host/process-spec' +import type { RemoteCommandChannel } from '../ssh/remote-install-transport' + +// Why setsid: a `wsl.exe --exec` command leads its own session; without it every command +// would share the loop's process group. Why fresh files per command: a backgrounded writer +// keeps the unlinked one, never the next command's output. +export const WSL_COMMAND_LOOP_SCRIPT = [ + 'm="$1"', + 'd=$(mktemp -d "${TMPDIR:-/tmp}/orca-wsl-exec.XXXXXX") || exit 97', + 'trap \'rm -rf "$d"\' EXIT', + "trap 'exit 129' HUP INT TERM", + '[ "$(printf eA== | base64 -d 2>/dev/null)" = x ] || exit 98', + 'if command -v setsid >/dev/null 2>&1; then s=setsid; else s=; fi', + 'printf \'%s R\\n\' "$m"', + 'while IFS= read -r l; do', + ' c=$(printf %s "$l" | base64 -d) || exit 99', + ' $s sh -c "$c" "$d/o" 2>"$d/e"', + ' r=$?', + ' printf \'%s O\\n\' "$m"; base64 <"$d/o"', + ' printf \'%s E\\n\' "$m"; base64 <"$d/e"', + ' printf \'%s X %s\\n\' "$m" "$r"', + ' rm -f "$d/o" "$d/e"', + 'done' +].join('\n') + +export const WSL_COMMAND_LOOP_ARGV0 = 'orca-wsl-exec-loop' + +const BASE64_LINE = /^[A-Za-z0-9+/=]*$/u + +type PendingCommand = { + channel: Duplex + stderr: PassThrough + stdout: string[] + errors: string[] + section: 'none' | 'out' | 'err' + settled: boolean +} + +export type WslCommandLoop = { + /** Resolves once the guest loop answered its self-test; rejects if it never can. */ + readonly ready: Promise + readonly busy: boolean + readonly alive: boolean + run(command: string): RemoteCommandChannel + /** Ends the loop at its next read; a running command finishes first. */ + end(): void + kill(): void + onIdle(listener: () => void): void +} + +export function wslCommandLoopMarker(): string { + return `__ORCA_WSL_EXEC_${randomUUID().replaceAll('-', '')}__` +} + +export function startWslCommandLoop(child: PipedChildProcess, marker: string): WslCommandLoop { + let pending: PendingCommand | null = null + let alive = true + let buffered = '' + let idleListener: () => void = () => {} + let markReady: () => void = () => {} + let failReady: (error: Error) => void = () => {} + let isReady = false + const ready = new Promise((resolve, reject) => { + markReady = resolve + failReady = reject + }) + // Callers that never await `ready` must not see an unhandled rejection. + ready.catch(() => {}) + + const settle = (code: number | null, signal: NodeJS.Signals | null, error?: Error): void => { + const command = pending + if (!command || command.settled) { + return + } + command.settled = true + pending = null + if (error) { + command.channel.destroy(error) + } else { + const out = Buffer.from(command.stdout.join(''), 'base64') + if (out.length > 0) { + command.channel.push(out) + } + command.channel.push(null) + const err = Buffer.from(command.errors.join(''), 'base64') + command.stderr.end(err.length > 0 ? err : undefined) + } + command.channel.emit('exit', code, signal) + // Like a wsl.exe child: `close` after its streams, so readers have their data first. + setImmediate(() => command.channel.emit('close', code, signal)) + if (alive && !error) { + idleListener() + } + } + + const fail = (reason: string): void => { + if (!isReady) { + failReady(new Error(reason)) + } + settle(null, null, new Error(reason)) + if (alive) { + kill() + } + } + + const onLine = (line: string): void => { + if (line.startsWith(`${marker} `)) { + const control = line.slice(marker.length + 1) + if (control === 'R' && !isReady) { + isReady = true + markReady() + } else if (control === 'O' && pending) { + pending.section = 'out' + } else if (control === 'E' && pending) { + pending.section = 'err' + } else if (control.startsWith('X ') && pending) { + const code = Number.parseInt(control.slice(2), 10) + settle(Number.isSafeInteger(code) ? code : null, null) + } else { + fail('The WSL command loop sent an unexpected frame') + } + return + } + if (!pending || pending.section === 'none') { + // Outside a frame: nothing the protocol writes, so ignore stray guest output. + return + } + if (!BASE64_LINE.test(line)) { + fail('The WSL command loop sent a malformed frame') + return + } + ;(pending.section === 'out' ? pending.stdout : pending.errors).push(line) + } + + const kill = (): void => { + if (child.exitCode === null && child.signalCode === null) { + child.kill() + } + } + + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + buffered += chunk + let newline = buffered.indexOf('\n') + while (newline >= 0) { + onLine(buffered.slice(0, newline).replace(/\r$/u, '')) + buffered = buffered.slice(newline + 1) + newline = buffered.indexOf('\n') + } + }) + // The loop's own stderr is never a command's; drain it so wsl.exe cannot block on it. + child.stderr.resume() + const ignore = (): void => {} + child.stdin.on('error', ignore) + child.stdout.on('error', ignore) + child.stderr.on('error', ignore) + child.on('error', (error) => { + alive = false + fail(error.message) + }) + child.on('close', (code, signal) => { + alive = false + if (!isReady) { + failReady(new Error(`The WSL command loop exited (${code ?? signal ?? 'unknown'})`)) + } + // A command cut off by the loop's exit reads like a killed wsl.exe child. + settle(null, signal ?? 'SIGTERM') + }) + + return { + ready, + get busy() { + return pending !== null + }, + get alive() { + return alive + }, + run(command) { + if (pending || !alive || !isReady) { + throw new Error('The WSL command loop is not idle') + } + const stderr = new PassThrough() + const channel = new Duplex({ + emitClose: false, + read() {}, + write: refuseStdin + }) + const entry: PendingCommand = { + channel, + stderr, + stdout: [], + errors: [], + section: 'none', + settled: false + } + pending = entry + Object.assign(channel, { + stdin: new Writable({ write: refuseStdin }), + stderr, + close: () => { + // The command runs inside the loop, so stopping it means stopping the loop. + if (!entry.settled) { + kill() + } + } + }) + child.stdin.write(`${Buffer.from(command, 'utf8').toString('base64')}\n`) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `stdin`, `stderr` and `close` were assigned above and `exit`/`close` are emitted by `settle`. + return channel as unknown as RemoteCommandChannel + }, + end() { + if (alive) { + child.stdin.end() + } + }, + kill, + onIdle(listener) { + idleListener = listener + } + } +} + +function refuseStdin(_chunk: unknown, _encoding: unknown, done: (error?: Error) => void): void { + done(new Error('This WSL command runs with stdin closed')) +} diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index 474c5a60fb6..c10a9cceb5b 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -32,7 +32,7 @@ import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder import { wslDistroHolderKey } from './wsl-distro-holder-record' import { readWslDistroRegistryId } from './wsl-distro-registry-id' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' -import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { createWslOrcadHostTransport, type WslOrcadHostTransport } from './wsl-orcad-host-transport' import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption' export type WslOrcadLaunch = @@ -67,7 +67,7 @@ export type WslOrcadDeployOptions = { signal?: AbortSignal /** Defaults to one created over `userData`; the app should pass its shared instance. */ holder?: WslDistroHolder - createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport + createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport & { dispose?(): void } readRegistryId?: (distro: string) => Promise } @@ -91,6 +91,19 @@ export async function deployAndLaunchWslOrcad( label: options.label ?? name } const conn = (options.createTransport ?? defaultTransport)(target) + try { + return await launchOverTransport(name, target, conn, options) + } finally { + conn.dispose?.() + } +} + +async function launchOverTransport( + name: string, + target: WslOrcadHostTarget, + conn: OrcadHostTransport, + options: WslOrcadDeployOptions +): Promise { const { signal } = options const kernel = await execCommand(conn, 'uname -r', { signal }) if (isWsl1KernelRelease(kernel)) { @@ -184,7 +197,7 @@ export async function deployAndLaunchWslOrcad( } } -function defaultTransport(target: WslOrcadHostTarget): OrcadHostTransport { +function defaultTransport(target: WslOrcadHostTarget): WslOrcadHostTransport { return createWslOrcadHostTransport({ target }) } diff --git a/src/main/wsl/wsl-orcad-host-transport-loops.test.ts b/src/main/wsl/wsl-orcad-host-transport-loops.test.ts new file mode 100644 index 00000000000..7642de92b6b --- /dev/null +++ b/src/main/wsl/wsl-orcad-host-transport-loops.test.ts @@ -0,0 +1,93 @@ +import { Readable } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '@orca/process-host' +import type { PipedChildProcess, PipedProcessSpec } from '@orca/process-host/process-spec' +import { execCommand } from '../ssh/ssh-relay-exec-command' +import { createWslOrcadHostTransport, type WslOrcadHostTransport } from './wsl-orcad-host-transport' + +const posix = process.platform !== 'win32' +const children: PipedChildProcess[] = [] +const transports: WslOrcadHostTransport[] = [] + +/** Runs what follows `--exec` with the local sh, as wsl.exe would inside the distro. */ +function localTransport() { + const specs: PipedProcessSpec[] = [] + const transport = createWslOrcadHostTransport({ + target: { + kind: 'wsl', + id: 'env-1', + distro: 'Ubuntu-24.04', + label: 'Ubuntu' + }, + spawn: (spec) => { + specs.push(spec) + const args = spec.args ?? [] + const child = spawnProcess({ + program: 'sh', + args: args.slice(args.indexOf('--exec') + 2) + }) + children.push(child) + return child + } + }) + transports.push(transport) + return { transport, specs } +} + +afterEach(() => { + transports.splice(0).forEach((transport) => transport.dispose()) + children.splice(0).forEach((child) => child.kill()) +}) + +describe.runIf(posix)('WSL transport command loops', () => { + it('runs a sequence of stdin-free probes over one wsl.exe', async () => { + const { transport, specs } = localTransport() + for (const word of ['uname', 'home', 'record', 'fence']) { + expect(await execCommand(transport, `echo ${word}`)).toBe(`${word}\n`) + } + expect(specs).toHaveLength(1) + expect(specs[0]?.args?.slice(0, 5)).toEqual(['-d', 'Ubuntu-24.04', '--exec', 'sh', '-c']) + expect(specs[0]?.args?.[6]).toBe('orca-wsl-exec-loop') + }) + + it('still gives a command that takes stdin its own wsl.exe', async () => { + const { transport, specs } = localTransport() + await execCommand(transport, 'echo first') + expect( + await execCommand(transport, 'cat', { + stdin: Readable.from([Buffer.from('piped')]) + }) + ).toBe('piped') + expect(specs).toHaveLength(2) + expect(specs[1]?.args).toEqual(['-d', 'Ubuntu-24.04', '--exec', 'sh', '-c', expect.any(String)]) + expect(specs[1]?.args?.[5]).toContain('cat') + }) + + it('falls back to one wsl.exe per command when the loop cannot start', async () => { + const specs: PipedProcessSpec[] = [] + const transport = createWslOrcadHostTransport({ + target: { + kind: 'wsl', + id: 'env-1', + distro: 'Ubuntu-24.04', + label: 'Ubuntu' + }, + spawn: (spec) => { + specs.push(spec) + const args = spec.args ?? [] + const shellArgs = args.slice(args.indexOf('--exec') + 2) + // A guest without base64: the loop's self-test fails and it exits. + const child = spawnProcess({ + program: 'sh', + args: shellArgs[2] === 'orca-wsl-exec-loop' ? ['-c', 'exit 98'] : shellArgs + }) + children.push(child) + return child + } + }) + transports.push(transport) + expect(await execCommand(transport, 'echo a')).toBe('a\n') + expect(await execCommand(transport, 'echo b')).toBe('b\n') + expect(specs).toHaveLength(3) + }) +}) diff --git a/src/main/wsl/wsl-orcad-host-transport.test.ts b/src/main/wsl/wsl-orcad-host-transport.test.ts index c07dfe3b13b..ae8753689d4 100644 --- a/src/main/wsl/wsl-orcad-host-transport.test.ts +++ b/src/main/wsl/wsl-orcad-host-transport.test.ts @@ -52,6 +52,7 @@ function transportWith(children: FakeChild[]) { const specs: PipedProcessSpec[] = [] const transport = createWslOrcadHostTransport({ target: { kind: 'wsl', id: 'env-1', distro: 'Ubuntu-24.04', label: 'My Ubuntu' }, + commandLoops: false, spawn: (spec) => { specs.push(spec) const next = children.shift() diff --git a/src/main/wsl/wsl-orcad-host-transport.ts b/src/main/wsl/wsl-orcad-host-transport.ts index 0d24b820ec2..e83cd4e52fc 100644 --- a/src/main/wsl/wsl-orcad-host-transport.ts +++ b/src/main/wsl/wsl-orcad-host-transport.ts @@ -9,12 +9,16 @@ import { createSshOperationAbortError, type SshExecOptions } from '../ssh/ssh-co import { writeStringViaExecStdin } from '../ssh/ssh-exec-stdin-file-transfer' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { wrapWslCommandProcess } from './wsl-command-channel' +import { WSL_COMMAND_LOOP_ARGV0, WSL_COMMAND_LOOP_SCRIPT } from './wsl-command-loop' +import { createWslCommandLoopPool } from './wsl-command-loop-pool' import { uploadDirectoryIntoWsl, wslGuestSourcePath } from './wsl-directory-upload' import { resolveWslExecutablePath } from './wsl-executable-path' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' export type WslOrcadHostTransport = OrcadHostTransport & { getTarget(): WslOrcadHostTarget + /** Ends the idle command loops now instead of after their idle timeout. */ + dispose(): void } type WslOrcadHostTransportOptions = { @@ -26,10 +30,13 @@ type WslOrcadHostTransportOptions = { * 20 MB). `guest-mount` copies from `/mnt/` when the guest can see the source. */ uploadSource?: 'tar' | 'guest-mount' + /** Test seam: false runs every command on its own wsl.exe. */ + commandLoops?: boolean } /** - * orcad's host connection to a WSL distro: every exec is its own `wsl.exe --exec sh -c` child. + * orcad's host connection to a WSL distro: an exec that may write stdin is its own + * `wsl.exe --exec sh -c` child; a stdin-free one reuses a pooled command loop when it can. * The distro is never a Windows host and has no SFTP, so every transfer runs over exec. */ export function createWslOrcadHostTransport( @@ -37,24 +44,34 @@ export function createWslOrcadHostTransport( ): WslOrcadHostTransport { const spawn = options.spawn ?? spawnProcess const { target } = options + const spawnWsl = (shellArgs: string[]) => { + const program = resolveWslExecutablePath() + const args = buildWslExecArgs(target.distro, shellArgs) + if (commandLineLength([program, ...args]) > MAX_COMMAND_LINE_CHARS) { + throw new Error('WSL command exceeds the Windows command-line limit') + } + return spawn({ + program, + args, + cwd: resolveWslInteropSpawnCwd(), + // Why WSL_UTF8: otherwise wsl.exe writes its own errors as UTF-16LE. + env: { ...process.env, WSL_UTF8: '1' } + }) + } + const loops = + options.commandLoops === false + ? null + : createWslCommandLoopPool({ + spawnLoop: (marker) => + spawnWsl(['sh', '-c', WSL_COMMAND_LOOP_SCRIPT, WSL_COMMAND_LOOP_ARGV0, marker]) + }) const transport: WslOrcadHostTransport = { // `wrapCommand` is SSH-only: there is no login shell between wsl.exe and `sh -c`. async exec(command: string, execOptions?: SshExecOptions): Promise { if (execOptions?.signal?.aborted) { throw createSshOperationAbortError() } - const program = resolveWslExecutablePath() - const args = buildWslExecArgs(target.distro, ['sh', '-c', command]) - if (commandLineLength([program, ...args]) > MAX_COMMAND_LINE_CHARS) { - throw new Error('WSL command exceeds the Windows command-line limit') - } - const child = spawn({ - program, - args, - cwd: resolveWslInteropSpawnCwd(), - // Why WSL_UTF8: otherwise wsl.exe writes its own errors as UTF-16LE. - env: { ...process.env, WSL_UTF8: '1' } - }) + const child = spawnWsl(['sh', '-c', command]) const channel = wrapWslCommandProcess(child) // Like an SSH channel open: a child that never started rejects here, not as a stream error. const spawnError = (error: Error): void => { @@ -68,6 +85,12 @@ export function createWslOrcadHostTransport( } return channel }, + async execWithClosedStdin(command, execOptions) { + return ( + (await loops?.run(command, execOptions?.signal)) ?? transport.exec(command, execOptions) + ) + }, + dispose: () => loops?.dispose(), // MEASURED: `nohup … &` dies when its `wsl.exe --exec` exits on a distro without systemd. orcadLaunchDetach: 'setsid', usesSystemSshTransport: () => false, diff --git a/src/shared/orcad-idle-exit.ts b/src/shared/orcad-idle-exit.ts index c0034117646..6ae458499f6 100644 --- a/src/shared/orcad-idle-exit.ts +++ b/src/shared/orcad-idle-exit.ts @@ -4,12 +4,7 @@ */ import { z } from 'zod' -/** - * Set only by a client's managed launch. Enables idle exit and names the host's activation - * fence, so an update or rollback in flight keeps the server up. User-started servers never - * carry it. - */ -export const ORCAD_MANAGED_ACTIVATION_ROOT_ENV = 'ORCA_ORCAD_MANAGED_ACTIVATION_ROOT' +export { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from './orcad-managed-launch-env' /** Test-only quiet period; a client forwards it to the servers it launches. */ export const ORCAD_E2E_IDLE_TIMEOUT_ENV = 'ORCA_E2E_ORCAD_IDLE_TIMEOUT_MS' export const ORCAD_IDLE_EXIT_TIMEOUT_MS = 15 * 60_000 diff --git a/src/shared/orcad-managed-launch-env.ts b/src/shared/orcad-managed-launch-env.ts new file mode 100644 index 00000000000..3adc94a117e --- /dev/null +++ b/src/shared/orcad-managed-launch-env.ts @@ -0,0 +1,6 @@ +/** + * Set only by a client's managed launch. Enables idle exit and names the host's activation + * fence, so an update or rollback in flight keeps the server up. User-started servers never + * carry it. Dependency-free so the orcad launcher can read it. + */ +export const ORCAD_MANAGED_ACTIVATION_ROOT_ENV = 'ORCA_ORCAD_MANAGED_ACTIVATION_ROOT'