Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240// node:child_process for `node <script>` children: the kernel runs the script as a new// process whose piped stdout and stderr, and whose exit, come back to the thread that// spawned it. `npm` is the guest npm script the kernel seeds, run under node as npm's// own bin is, and `npx` the same script started as npx. Other commands have nothing to// run them and fail as Node does for a missing binary (an async 'error', ENOENT). The// sync and exec forms refuse.import type { ChildStdio, ExitStatus, RuntimeToKernel, SyscallSpec } from "../shared/protocol";import { capture, runInFrame, type Context } from "./async_hooks";import { Buffer } from "./buffer";import { EventEmitter, type EventEmitterConstructor } from "./events";import type { Loop } from "./loop";import type { GuestProcess } from "./process";import { refused, unsupported } from "./refuse";import { Readable, Writable } from "./stream";
export interface ChildProcessDeps { loop: Loop; callSync<K extends keyof SyscallSpec>(op: K, args: SyscallSpec[K]["args"]): SyscallSpec[K]["ret"]; post(msg: RuntimeToKernel): void; process(): GuestProcess; resolvePath(p: string): string;}
export interface ChildProcesses { module: Record<string, unknown>; childStdio(pid: number, stream: "stdout" | "stderr", bytes: Uint8Array): void; childExited(pid: number, status: ExitStatus): void;}
type StdioOption = "pipe" | "inherit" | "ignore" | "overlapped" | number | null | undefined | object;
interface SpawnOptions { cwd?: string | URL; env?: Record<string, unknown>; stdio?: StdioOption | StdioOption[]; argv0?: string; windowsHide?: boolean; detached?: boolean; shell?: boolean | string;}
const isNode = (command: string): boolean => command === "node" || command.endsWith("/node");/** npm and npx, by name or at the links a PATH lookup finds: the scripts they run. */const NPM_BINS: Record<string, string> = { npm: "/usr/local/lib/node_modules/npm/bin/npm-cli.js", "/usr/local/bin/npm": "/usr/local/lib/node_modules/npm/bin/npm-cli.js", npx: "/usr/local/lib/node_modules/npm/bin/npx-cli.js", "/usr/local/bin/npx": "/usr/local/lib/node_modules/npm/bin/npx-cli.js",};const isNpm = (command: string): boolean => Object.hasOwn(NPM_BINS, command);
/** Node's stdio option, normalized to what the kernel can route. */function parseStdio(option: SpawnOptions["stdio"]): ChildStdio { const list = Array.isArray(option) ? option : [option, option, option]; const mode = (v: StdioOption, fd: number): "pipe" | "inherit" | "ignore" => { if (v === undefined || v === null || v === "pipe" || v === "overlapped") return "pipe"; if (v === "ignore") return "ignore"; if (v === "inherit" || v === fd) return "inherit"; // A stream object stands for its fd: process.stdout and process.stderr inherit. if (typeof v === "object" && (v as { fd?: number }).fd === fd) return "inherit"; throw unsupported("child_process", `stdio[${fd}] ${String(v)}`, "a child's fds are pipes, the parent's own, or ignored"); }; const stdin = mode(list[0], 0); return { stdin: stdin === "inherit" ? "ignore" : stdin, stdout: mode(list[1], 1), stderr: mode(list[2], 2) };}
function spawnError(command: string, args: string[]): Error { return Object.assign(new Error(`spawn ${command} ENOENT`), { errno: -2, code: "ENOENT", syscall: `spawn ${command}`, path: command, spawnargs: args, });}
export function makeChildProcess(deps: ChildProcessDeps): ChildProcesses { const { loop } = deps; const children = new Map<number, ChildProcess>();
/** A pipe end the parent holds. Node's are sockets, whose ref/unref only decide * whether the pipe holds the loop; here the child process alone does. */ const pipeEnd = <T extends object>(stream: T): T => Object.assign(stream, { ref() { return stream; }, unref() { return stream; } });
class ChildProcess extends (EventEmitter as unknown as EventEmitterConstructor) { pid: number | undefined = undefined; exitCode: number | null = null; signalCode: string | null = null; killed = false; connected = false; readonly spawnfile: string; readonly spawnargs: string[]; readonly stdin: Writable | null; readonly stdout: Readable | null; readonly stderr: Readable | null; readonly stdio: Array<Writable | Readable | null>; #ref = true; #holding = false; #running = false; /** spawn() creates the handle (probed: 'exit', 'close' and stdout/stderr 'data' * all run in the frame current at spawn(), not when the kernel's childStdio / * childExited message happens to arrive). */ readonly #frame: Context = capture();
constructor(command: string, args: string[], options: SpawnOptions) { super(); this.spawnfile = command; this.spawnargs = [command, ...args]; const stdio = parseStdio(options.stdio); this.stdin = stdio.stdin === "pipe" ? pipeEnd(this.#stdinPipe()) : null; this.stdout = stdio.stdout === "pipe" ? pipeEnd(new Readable({ read() {} })) : null; this.stderr = stdio.stderr === "pipe" ? pipeEnd(new Readable({ read() {} })) : null; this.stdio = [this.stdin, this.stdout, this.stderr];
if (!isNode(command) && !isNpm(command)) { loop.nextTick(() => { this.emit("error", spawnError(command, args)); this.#close(-2, null); }); return; } const [script, ...rest] = isNpm(command) ? [NPM_BINS[command]!, ...args] : args; if (script === undefined || script.startsWith("-")) { throw unsupported("child_process", `spawning node ${script ?? "with no script"}`, "a child runs a script file; node's flags and REPL are not modelled"); } const proc = deps.process(); const cwd = options.cwd === undefined ? proc.cwd() : deps.resolvePath(options.cwd instanceof URL ? decodeURIComponent(options.cwd.pathname) : options.cwd); const envSource = options.env ?? proc.env; const env = Object.fromEntries(Object.entries(envSource).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])); const entry = script.startsWith("/") ? script : `${cwd.replace(/\/$/, "")}/${script}`; this.pid = deps.callSync("spawn", { entry, argv: rest, env, cwd, stdio }).pid; this.#running = true; children.set(this.pid, this); this.#updateHold(); loop.nextTick(() => this.emit("spawn")); }
kill(signal: string | number = "SIGTERM"): boolean { if (!this.#running || this.pid === undefined) return false; try { deps.process().kill(this.pid, signal); } catch (e) { if ((e as { code?: string }).code === "ESRCH") return false; throw e; } this.killed = true; return true; }
ref(): void { this.#ref = true; this.#updateHold(); }
unref(): void { this.#ref = false; this.#updateHold(); }
/** @internal */ output(stream: "stdout" | "stderr", bytes: Uint8Array): void { runInFrame(this.#frame, () => this[stream]?.push(Buffer.from(bytes))); }
/** @internal */ ended(status: ExitStatus): void { this.#running = false; children.delete(this.pid!); this.#updateHold(); this.#close(status.code, status.signal); }
/** The pipes reach EOF first, then 'exit' in a later callback, as libuv reads a * dead child's pipes before its exit; 'close' once the streams have ended. A * child that never started (code -2) has no 'exit'. */ #close(code: number | null, signal: string | null): void { runInFrame(this.#frame, () => { this.exitCode = code; this.signalCode = signal; this.stdout?.push(null); this.stderr?.push(null); loop.enqueueCompletion(() => { if (code !== -2) this.emit("exit", code, signal); loop.enqueueCompletion(() => this.emit("close", code, signal)); }); }); }
#stdinPipe(): Writable { const send = (bytes: Uint8Array | null): void => { if (this.pid !== undefined && this.#running) deps.post({ t: "childStdin", pid: this.pid, bytes }); }; return new Writable({ write(chunk: unknown, _encoding: string, cb: (e?: Error | null) => void) { send(typeof chunk === "string" ? Buffer.from(chunk) : chunk as Uint8Array); cb(); }, final(cb: (e?: Error | null) => void) { send(null); cb(); }, destroy(error: Error | null, cb: (e?: Error | null) => void) { send(null); cb(error); }, }); }
#updateHold(): void { const hold = this.#running && this.#ref; if (hold === this.#holding) return; this.#holding = hold; if (hold) loop.hold(); else loop.release(); } }
function spawn(command: string, args?: string[] | SpawnOptions, options?: SpawnOptions): ChildProcess { if (!Array.isArray(args)) [args, options] = [[], args]; if (options?.shell) throw unsupported("child_process", "spawn({ shell })", "there is no shell to run a command line"); return new ChildProcess(String(command), args.map(String), options ?? {}); }
const reason = "a parent cannot block on a child or run a shell command line; spawn() of node, npm and npx is supported"; return { module: { ChildProcess, spawn, exec: refused("child_process", "exec", reason), execFile: refused("child_process", "execFile", reason), execSync: refused("child_process", "execSync", reason), execFileSync: refused("child_process", "execFileSync", reason), spawnSync: refused("child_process", "spawnSync", reason), fork: refused("child_process", "fork", "there is no IPC channel between processes yet"), }, childStdio(pid, stream, bytes) { children.get(pid)?.output(stream, bytes); }, childExited(pid, status) { children.get(pid)?.ended(status); }, };}