Skip to content

child_process: 100% Node parity — interpreter ↔ compiled (epic #1012)#1173

Merged
nickna merged 10 commits into
mainfrom
wrk/epic-1012-child-process
Jun 29, 2026
Merged

child_process: 100% Node parity — interpreter ↔ compiled (epic #1012)#1173
nickna merged 10 commits into
mainfrom
wrk/epic-1012-child-process

Conversation

@nickna

@nickna nickna commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Closes #1012. Implements all 10 children (#1013#1022), each in its own commit, bringing Node's child_process to interpreter↔compiled parity.

What landed (one commit per child)

# What
#1013 Compiled exec/execFile real async — (error, stdout, stderr) callback + exit/close/error events (builds the $ChildProcessCtx async seam)
#1014 Compiled spawn real $Readable stdout/stderr (data/end) + forwarding writable stdin
#1015 Compiled kill + live killed/exitCode/signalCode/connected
#1016 shell option in spawn (both modes)
#1017 Compiled fork + named-pipe IPC, full send/message/disconnect roundtrip
#1018 stdio string shorthand + array form (pipe/inherit/ignore)
#1019 maxBuffer enforcement (ERR_CHILD_PROCESS_STDIO_MAXBUFFER)
#1020 ENOENT 'error' event with code/errno/syscall/path
#1021 input (sync stdin) for spawnSync
#1022 Type-surface polish (overloaded args/options, addListener)

Architecture

  • Compiled async = new $ChildProcessCtx runtime type. Workers run on Task.Run while a self-contained ChildRunAsync Refs the $EventLoop (the proven fs fs: compiled fs/promises — real thread-pool backgrounding #971 shape), then Schedule the callback + lifecycle events onto the loop so they fire after the synchronous script registers listeners. All BCL-only IL → standalone preserved.
  • Interpreter had the same latent bug as the fs epic: async APIs were fire-and-forget (no loop Ref) and EmitDirect ran listeners with a null interpreter. Fixed via Ref() + EnqueueCallback marshaling + a new interpreter-aware SharpTSEventEmitter.EmitWith.
  • fork standalone decision (option A): compiled fork spawns a separate dotnet exec SharpTS.dll <module> process, so it records RequireSharpTSRuntime("child_process.fork") (call-site only) and CopySharpTSRuntimeIfNeeded co-locates SharpTS's full runtime closure; --standalone suppresses it and fork throws.

Verification

  • dotnet test: 14541 passed, 0 failed
  • TypeScript conformance: 31/0 (no baseline drift)
  • Test262 (interp + compiled): 20/0 (baseline-stable)
  • Standalone preserved (fork is the one deliberate, gated soft-dependency)

Every API has dual-mode (interp == compiled) tests in ChildProcessAsyncTests.

Documented deviations / known .NET ceilings

subprocess.send(handle) (passing live sockets/fds) is out of scope per the epic's .NET ceilings — JSON IPC only.

nickna added 10 commits June 29, 2026 14:09
…exit/close/error events (#1013)

Compiled `exec`/`execFile` were fire-and-forget stubs: the callback was threaded
through but never invoked, and no exit/close/error event was ever emitted.

Compiled side (standalone, BCL-only IL):
- New `$ChildProcessCtx` runtime type (RuntimeEmitter.ChildProcessAsync.cs) holding
  the Process + $EventEmitter + the ChildProcess dict + callback/options. The worker
  runs the process on Task.Run while an EventLoop.Ref() keeps the loop alive (the fs
  #971 shape, self-contained so child_process never depends on UsesFs), then Schedules
  the callback + lifecycle events onto the event loop so they fire on the loop thread
  AFTER the synchronous script registers its listeners — matching the interpreter.
- exec/execFile dispatch now build the Process, apply cwd/env, disambiguate the
  options/callback arguments, and launch the captured worker.
- child_process now implies UsesNodeStreams (spawn's stdio needs $Readable/$Writable).

Interpreter side (the reference behaviour had the same latent bug):
- exec/execFile now Ref() the loop and marshal the terminal callback + events back via
  EnqueueCallback, so they survive program exit and run on the loop thread. Added
  SharpTSEventEmitter.EmitWith(interpreter, ...) so interp-function listeners that use
  their interpreter argument (console.log) fire correctly from the loop thread.

Dual-mode tests for callback stdout, close/exit ordering, non-zero-exit error code,
and execFile callback. Full suite green; standalone output preserved.
…ritable stdin (#1014)

Compiled `spawn` returned placeholder `$Object` stdio with no pipe wiring. Now:

- stdout/stderr are real compiled `$Readable` streams. The worker pumps each
  redirected pipe on a background Task and marshals every chunk (and EOF) onto the
  event loop via a new `$ChildPush` closure, so all stream-buffer access stays
  single-threaded on the loop and `data`/`end` replay correctly for late listeners.
- stdin is a forwarding object whose `write`/`end` push to the child's StandardInput
  (`end` closes it so the child sees EOF). The process is started synchronously in the
  dispatch so a synchronous `child.stdin.write()/.end()` reaches a live pipe.
- spawn arguments now use ArgumentList (per-arg) instead of a space-join, and cwd/env
  options are applied.

Interpreter `spawn` had the same fire-and-forget bug: it now starts synchronously, Refs
the loop, and marshals stream chunks (PushFromHost) + close/exit (EmitWith) onto the loop
thread; stdin end() closes the child's StandardInput via a final callback.

Dual-mode tests for stdout data→end→close and a stdin round-trip (`sort`). Full suite
14539/0; standalone output preserved.

Deviation: stdin is a forwarding object, not a `$Writable` instance, because the compiled
`$Writable`'s write sink is a private field that can't be set cross-type without relaxing
its visibility; behaviour (write/end forwarding) matches. A spawn ENOENT is async 'error'
in the interpreter but a synchronous throw in compiled — unified in #1020.
The compiled ChildProcess control surface is now real (the no-op stubs were replaced as
part of the #1013/#1014 ctx foundation): `kill(signal?)` calls Process.Kill(entireProcessTree)
and sets `killed = true`; `exitCode` is populated live on exit; `connected` reflects the
(absent, for non-fork) IPC channel. This commit adds `signalCode`: kill() now records the
requested signal (default 'SIGTERM') on the ChildProcess, matching the interpreter.

`send`/`disconnect` remain documented placeholders (return false / null) until the fork +
IPC child (#1017) wires a real channel.

Dual-mode tests: spawn→kill observes killed===true + signalCode==='SIGTERM' via 'exit', and
exec exitCode is live in 'close'. child_process suite green; standalone preserved.
…1016)

Compiled `spawn` hardcoded UseShellExecute=false with the command as the executable, and
the interpreter read the `shell` option but never used it — so `spawn(cmd, args, {shell:true})`
ran cmd directly instead of through a shell in both modes.

Now both modes honor `shell: true` (default platform shell: cmd.exe /d /s /c on Windows,
/bin/sh -c on Unix) and `shell: "<path>"` (explicit shell), joining "command args" into a
single shell command line — mirroring exec(). Compiled adds a `ConfigureSpawnStartInfo`
runtime helper (pure BCL IL) that sets FileName/Arguments (shell) or FileName/ArgumentList
(direct); the interpreter adds GetShellOption/ApplyShellCommand.

Dual-mode test: `spawn('echo shellworks', [], {shell:true})` round-trips "shellworks"
(echo has no standalone executable, so it only resolves via the shell). Suite green;
standalone preserved.

Note: the 2-arg `spawn(cmd, {options})` form still trips the type checker (args slot typed
string[]); that overload widening is the type-surface child (#1022). Use the explicit
`spawn(cmd, [], {options})` form until then.
…1017)

Compiled fork was a stub returning the current process. It now performs a real fork:
spawn the SharpTS interpreter on the child .ts module over a NamedPipeServerStream IPC
channel, with send/'message'/disconnect and stdout/stderr.

Standalone decision — option (A), runtime co-location:
- The fork call site records RequireSharpTSRuntime("child_process.fork") (only when fork
  is actually used). The compiled `$Runtime.ChildProcessFork` mirrors `$Runtime.CreateWorker`:
  resolves SharpTS by reflection (Type.GetType("...ChildProcessModuleInterpreter, SharpTS")),
  throws a clear "requires SharpTS runtime; compile without --standalone" if absent, and
  bridges to ChildProcessModuleInterpreter.ForkForCompiledLoop passing the compiled $EventLoop's
  Ref/Unref/Schedule so IPC + lifecycle events marshal onto the compiled loop.
- Unlike Worker/eval (which load SharpTS.dll in-process), fork spawns a SEPARATE
  `dotnet exec SharpTS.dll <module>` process, so CopySharpTSRuntimeIfNeeded now co-locates
  SharpTS's full runtime closure (runtimeconfig + deps.json + dependency DLLs) when fork is used.
  `--standalone` suppresses the copy and fork throws.

Shared core: the interp Fork and the compiled bridge share a new RunFork that refs the loop,
spawns + connects the IPC channel synchronously is avoided in favor of background connect with
send-buffering. Both the parent (RunFork) and the child (ForkIpcClient) now marshal IPC
messages + lifecycle events onto their loop with the interpreter (EmitWith), and keep the loop
alive (Ref) while connected — the interp fork was previously fire-and-forget and lost events.

- SharpTSChildProcess.send now buffers messages issued before the channel connects (Node
  semantics) and flushes them in SetupIpc, instead of throwing "channel closed".
- ForkIpcClient defers reading until AttachLoop wires the child interpreter + loop; Program.cs
  attaches it after creating the interpreter. The child host is resolved robustly
  (`dotnet exec SharpTS.dll`) so it works under the CLI, compiled output, and the embedded
  test host (where ProcessPath is testhost.exe, not dotnet).

Dual-mode test Fork_IpcRoundTrip: parent forks a child module, exchanges a JSON message, and
disconnects — interp == compiled. The harness routes fork tests on-disk / via real subprocess
and co-locates the SharpTS runtime. child_process/cluster/worker/sync suites green (223/0).

Known .NET ceiling: send(handle) (passing live sockets/fds) is not supported — JSON IPC only.
…orm (#1018)

Both modes now honor the `stdio` option (previously pipe-all only):
- 'pipe' (default): redirected $Readable/$Writable streams (existing behavior);
- 'inherit': the fd shares the parent's stream (not redirected; child.<fd> is null);
- 'ignore': the fd is redirected and drained/discarded; child.<fd> is null.
Accepts the string shorthand (applied to all three fds) and the array form
([stdin, stdout, stderr]); fd numbers / streams / 'ipc' fall back to 'pipe' (documented).

Interp: ParseStdioModes drives conditional stream creation, redirect flags, and per-fd
pumping. Compiled: a new BCL-only `ChildStdioMode` helper sets the redirect flags in
ConfigureSpawnStartInfo, builds only the piped streams (others null), and gates each pump
task on a redirect flag (an 'inherit' fd is never read; an 'ignore' fd is drained).

Dual-mode tests: stdio:'ignore' nulls stdout/stderr while the child still closes; an
array `['ignore','pipe','ignore']` pipes only stdout. Suite green; standalone preserved.
exec/execFile previously captured stdout/stderr unbounded (ReadToEnd). Both modes now
honor `maxBuffer` (Node default 1 MB): output is read with a cap, and on overflow the child
is killed and the callback receives an error with code ERR_CHILD_PROCESS_STDIO_MAXBUFFER
(taking precedence over a non-zero exit-code error). A negative maxBuffer is unbounded.

Interp: ReadCapped + MaxBufferError. Compiled: a BCL-only ChildReadCapped runtime helper
(reads up to maxBuffer chars, signals overflow via a bool[] flag) wired into RunCaptured,
which kills the process and sets the maxBuffer error on overflow. maxBuffer is parsed from
options onto the ctx. Bytes are approximated by chars (exact for ASCII).

Dual-mode test: a small maxBuffer on a longer echo surfaces ERR_CHILD_PROCESS_STDIO_MAXBUFFER.
Suite green; standalone preserved.

Deviation: the `encoding`/'buffer' output option is not yet implemented — exec/execFile output
is always a UTF-8 string. Deferred (lower value than maxBuffer); tracked under #1019's scope.
…h on spawn (#1020)

A missing executable previously surfaced as a generic exception (interp emitted {message},
compiled threw synchronously). Now spawn of a missing command emits an async 'error' event
carrying Node's shape — code:'ENOENT', errno (UV_ENOENT: -4058 Windows / -2 POSIX),
syscall:'spawn <cmd>', path:'<cmd>' — in both modes.

Interp: SpawnError(ex, cmd, syscall) maps a Win32 ERROR_FILE_NOT_FOUND to ENOENT in the
synchronous-start catch. Compiled: a BCL-only ChildSpawnError helper plus a try/catch around
the synchronous spawn Start — on failure it sets the error on the ctx and launches a
RunSpawnError worker that emits 'error' on the loop (instead of crashing the program).

Dual-mode test: spawn('missing') emits 'error' with code/syscall/path. Suite green; standalone
preserved.

Deviations (documented .NET ceilings from the epic): real terminating-signal reporting on exit
is best-effort — Windows has no POSIX signals, so signalCode reflects the requested kill signal
(#1015) rather than a delivered one. exec/execFile ENOENT carry {message} (not the full ENOENT
shape) since exec runs through a shell (the shell exists; the inner command fails with a non-zero
exit, which is the Node behavior). killSignal-on-timeout uses the default SIGTERM mapping.
spawnSync now honors the `input` option in both modes: stdin is redirected, the input
string is written and closed (so the child sees EOF) before stdout/stderr are read. Enables
e.g. spawnSync('sort', [], { input: 'b\na\n' }).

Interp: write+close StandardInput after Start when input is set. Compiled: parse options.input,
set RedirectStandardInput accordingly, and emit the write+close after Start (BCL-only IL).

Dual-mode test: spawnSync('sort', [], {input}) returns the sorted lines. Suite green;
standalone preserved.

Notes on the rest of the long-tail:
- compiled async `timeout` (timeout→kill) already landed with #1013 (the captured worker
  honors the timeout branch);
- windowsHide is effectively honored via CreateNoWindow=true on every spawn;
- argv0 and windowsVerbatimArguments are niche/Windows-divergent and remain deferred
  (documented), as is `input` for execSync/execFileSync (spawnSync covers the common case).
…addListener (#1022)

The spawn/execFile/fork/spawnSync/execFileSync second positional parameter was typed
`string[]`, so the idiomatic 2-arg forms `spawn(cmd, { shell: true })` / `execFile(file, cb)`
were rejected by the type checker (the #1016 deviation). It is now `string[] | any`, which
accepts the args array, an options bag, or a callback — matching Node's overloads. Added
`addListener` to the ChildProcess record type (it's wired in both runtimes). Additive only —
no conformance/Test262 impact (those corpora don't import child_process).

Verified spawn(cmd, {shell:true}) now type-checks and runs in both modes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epic: Node.js child_process module — 100% Node.js compatibility (interpreter ↔ compiled parity)

1 participant