mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
perf(wsl): pooled wsl.exe command loops and orcad compile cache on the WSL wake path (C4 follow-up) (#27923)
* perf(wsl): run stdin-free probes over pooled wsl.exe command loops; cache orcad's compiled bundle The WSL wake path made ~37 sequential wsl.exe starts at ~180 ms each. Stdin-free execCommand calls now go through a few long-lived wsl.exe sh loops (one command at a time per loop, own session, stdin at EOF, base64-framed output). SSH has no such method, so its commands and options are unchanged. orcad enables Node's compile cache inside a client-managed slot, and hands it to the profile preflight child, which compiles the same bundle. * fix(orcad): share the slot compile cache with the preflight only in a managed slot An unmanaged orcad started with its own NODE_COMPILE_CACHE kept that location for its preflight before; never redirect it into the install dir.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> {
|
||||
// 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) } : {}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<RemoteCommandChannel>
|
||||
/**
|
||||
* 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<RemoteCommandChannel>
|
||||
usesSystemSshTransport(): boolean
|
||||
/** Changes whenever the underlying transport is replaced; scopes per-transport verdicts. */
|
||||
getConnectGeneration(): number
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -73,7 +73,7 @@ export function sshCommandExitError(
|
||||
}
|
||||
|
||||
export async function execCommand(
|
||||
conn: Pick<RemoteInstallTransport, 'exec' | 'usesSystemSshTransport'>,
|
||||
conn: Pick<RemoteInstallTransport, 'exec' | 'execWithClosedStdin' | 'usesSystemSshTransport'>,
|
||||
command: string,
|
||||
options?: ExecCommandOptions
|
||||
): Promise<string> {
|
||||
@@ -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)
|
||||
|
||||
@@ -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<RemoteCommandChannel | null>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
type WslCommandLoopPoolOptions = {
|
||||
/** Starts `wsl.exe --exec sh -c <loop script> <argv0> <marker>`. */
|
||||
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<WslCommandLoop>()
|
||||
const reserved = new Set<WslCommandLoop>()
|
||||
const idleTimers = new Map<WslCommandLoop, ReturnType<typeof setTimeout>>()
|
||||
// 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<void> {
|
||||
return new Promise<void>((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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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" </dev/null >"$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<void>
|
||||
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<void>((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'))
|
||||
}
|
||||
@@ -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<string | null>
|
||||
}
|
||||
|
||||
@@ -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<WslOrcadLaunch> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<drive>` 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<RemoteCommandChannel> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user