child_process: 100% Node parity — interpreter ↔ compiled (epic #1012)#1173
Merged
Conversation
…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.
This was referenced Jun 30, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1012. Implements all 10 children (#1013–#1022), each in its own commit, bringing Node's
child_processto interpreter↔compiled parity.What landed (one commit per child)
exec/execFilereal async —(error, stdout, stderr)callback +exit/close/errorevents (builds the$ChildProcessCtxasync seam)spawnreal$Readablestdout/stderr (data/end) + forwarding writable stdinkill+ livekilled/exitCode/signalCode/connectedshelloption inspawn(both modes)fork+ named-pipe IPC, full send/message/disconnectroundtripstdiostring shorthand + array form (pipe/inherit/ignore)maxBufferenforcement (ERR_CHILD_PROCESS_STDIO_MAXBUFFER)'error'event withcode/errno/syscall/pathinput(sync stdin) forspawnSyncaddListener)Architecture
$ChildProcessCtxruntime type. Workers run onTask.Runwhile a self-containedChildRunAsyncRefs the$EventLoop(the proven fs fs: compiledfs/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.EmitDirectran listeners with a null interpreter. Fixed viaRef()+EnqueueCallbackmarshaling + a new interpreter-awareSharpTSEventEmitter.EmitWith.forkspawns a separatedotnet exec SharpTS.dll <module>process, so it recordsRequireSharpTSRuntime("child_process.fork")(call-site only) andCopySharpTSRuntimeIfNeededco-locates SharpTS's full runtime closure;--standalonesuppresses it andforkthrows.Verification
dotnet test: 14541 passed, 0 failedEvery API has dual-mode (interp == compiled) tests in
ChildProcessAsyncTests.Documented deviations / known .NET ceilings
encoding/'buffer'output deferred (child_process:maxBufferenforcement +encoding(Buffer) output #1019) — output is always a UTF-8 string;maxBufferis fully implemented both modes.exec/execFilerun via a shell that exists, so a missing inner command exits non-zero (matching Node). Real signal-on-exit reporting is best-effort (Windows has no POSIX signals);signalCodereflects the requested kill signal.inputforspawnSync;argv0/windowsVerbatimArgumentsdeferred (niche),windowsHide≈ covered byCreateNoWindow, compiled asynctimeoutlanded in child_process: compiledexec/execFilereal async — callbacks + exit/close/error events #1013.$Writableinstance (its write sink is a private field); write/end behavior matches.subprocess.send(handle)(passing live sockets/fds) is out of scope per the epic's .NET ceilings — JSON IPC only.