Repository navigation
Add a cleaned up child_processes API that cleans the API up for child process spawning, like how fs/promises cleaned up fs #54799
Description
Activity
- addedfeature requestIssues requesting new Node.js features.Issues requesting new Node.js features.
on Sep 6, 2024 - addedchild_processIssues and PRs related to the child_process subsystem.Issues and PRs related to the child_process subsystem.
on Sep 6, 2024 What if we make the existing
ChildProcessmore ergonomic instead?const child = spawn('ls', ['-lh', '/usr']); // reasonable, creates a child process // Now I want to read its stdout and wait for it to close, this is verbose: const output = Buffer.concat(await child.stdout.toArray()).toString(); // What if instead we could do: const output2 = await child.text(); // Or as a one liner console.log(await spawn('ls', ['-lh', '/usr']).text());
?
What if we make the existing
ChildProcessmore ergonomic instead?const child = spawn('ls', ['-lh', '/usr']); // reasonable, creates a child process // Now I want to read its stdout and wait for it to close, this is verbose: const output = Buffer.concat(await child.stdout.toArray()).toString(); // What if instead we could do: const output2 = await child.text(); // Or as a one liner console.log(await spawn('ls', ['-lh', '/usr']).text());
?
@benjamingr I also considered that (and forgot to list it) and I even tried that route first. However, I ultimately found it to be untenable:
- The current
child_processAPI doesn't correctly escape stuff on Windows, and fixing Windows argument escaping would likely breakcross-spawn. Thus, to avoid ecosystem fragmentation, a separate method is needed. - The current semantics for
options.stdiowould require reserving several encoding keywords if you were to add that automatic string decoding. And sharing a namespace for child FD types and encoding strings just feels wrong. - Part of the point of having new factories is to provide better defaults. For instance, always spawning without the shell window in Windows or at least doing that by default.
- And while this is a minor point, adding
.wait()to every invocation in shell script-like contexts, possibly tens of times in just a single file, is incredibly inconvenient. One of my goals with this was to avoid needing to wrap calls in most cases, at the cost of slightly complicating advanced IPC cases.
Will note that the
http.request/XMLHttpRequesttofetchshift was also an inspiration here.- The current
Updated the proposal to make a number of revisions and simplifications. It's still similar, but different enough to merit a re-read.
One of my goals was to allow
forkandsystemto be easily written as simple wrappers ofexec. For example, here'ssystem:export function system(script, args, opts) { if (!Array.isArray(args)) { opts ??= args args = [] } let {execPath, execArgv} = opts ?? {} if (process.platform === "win32") { execPath ??= process.env.COMSPEC || "cmd.exe" execArgv ??= ["/d", "/s", "/c"] } else { execPath ??= "sh" execArgv ??= ["-c"] } return exec( execPath, [...execArgv, ...args], {...opts, pathLookup: true}, ) } export function fork(script, args, opts) { if (!Array.isArray(args)) { opts ??= args args = [] } let {execPath, execArgv, env, fds} = opts ?? {} execPath ??= "node" execArgv ??= [] const normalized = {...fds} const ports = [] for (const fd of Object.keys(normalized)) { if (!/^\d+$/.test(normalized)) continue const source = normalized[fd] if (!(source instanceof MessagePort)) continue if (isTransferred(source)) throw new Error("...") ports.push({fd, source}) } let channelFds = "" for (const {fd, source} of ports) { // `convertToStream` is of course non-trivial normalized[fd] = convertToStream(port) channelFds += "," + fd } return exec( execPath, [...execArgv, ...args], { ...opts, pathLookup: true, env: channelFds ? {...env, NODE_CHANNEL_FD: channelFds.slice(1)} : env, } ) }
github-actions commented
on Mar 10, 2025 on Mar 10, 2025 – with GitHub ActionsContributorMore actionsThere has been no activity on this feature request for 5 months. To help maintain relevant open issues, please add the never-stale
Issues and PRs exempt from automated stale handling. label or close this issue if it should be closed. If not, the issue will be automatically closed 6 months after the last non-automated comment.
For more information on how the project manages feature requests, please consult the feature request management document.- addedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Mar 10, 2025 bump for non-staleness
- removedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Mar 11, 2025 There has been no activity on this feature request for 5 months. To help maintain relevant open issues, please add the never-stale
Issues and PRs exempt from automated stale handling. label or close this issue if it should be closed. If not, the issue will be automatically closed 6 months after the last non-automated comment.
For more information on how the project manages feature requests, please consult the feature request management document.- addedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Sep 7, 2025 Last bump, in case it turns high-priority for others. It's low enough priority for me now that I'll go ahead and let it get auto-closed next time (about 6 months from now).
- removedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Sep 13, 2025 github-actions commented
on Mar 12, 2026 on Mar 12, 2026 – with GitHub ActionsContributorMore actionsThere has been no activity on this feature request for 5 months. To help maintain relevant open issues, please add the never-stale
Issues and PRs exempt from automated stale handling. label or close this issue if it should be closed. If not, the issue will be automatically closed 6 months after the last non-automated comment.
For more information on how the project manages feature requests, please consult the feature request management document.- addedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Mar 12, 2026 github-actions commented
on Apr 11, 2026 on Apr 11, 2026 – with GitHub ActionsContributorMore actionsThere has been no activity on this feature request and it is being closed. If you feel closing this issue is not the right thing to do, please leave a comment.
For more information on how the project manages feature requests, please consult the feature request management document.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsAwaiting Triage
What is the problem this feature will solve?
child_processis, in my experience, one of the most commonly wrapped APIs by far. It's especially common to wrap it in promise environments.cross-spawnhas over 50 million weekly downloads.It all stems from a few major issues:
What is the feature you are proposing to solve the problem?
I'm thinking of the following API, in
child_process/promises:result = await promises.exec(command, args?, options?)to spawn a normal commandresult = await promises.system(command, args?, options?)to spawn a shell commandresult = await promises.fork(command, args?, options?)to spawn a child with an IPC channelOptions and arguments:
commandis what to run.exec: the binary to run, may be afile:URLsystem: the shell script to runfork: the Node script to run, may be afile:URLargsis an array of arguments to pass to the script or command and it defaults to the empty array.cross-spawn's behavior.optionsproperties still work, with the same defaults:options.detachedoptions.cwdoptions.envoptions.argv0options.uidoptions.gidoptions.signalis now an object, where keys are the signal names and values areAbortSignals and async iterables that can trigger them.options.refdetermines whether the process starts out ref'd.options.execPathforsystemandforkand represents the path to use. Unlike inchild_process.spawn, this is not a complete command. Defaults:system:"sh"in *nix,process.env.COMSPEC || "cmd.exe"on Windowsfork:"node"options.execArgvprovides the list of arguments to pass before passing the script. Defaults:system:["-c"]on *nix,["/d", "/s", "/c"]on Windowsfork:[]options.pathLookuptotrue(default) to use the system path to locate the target binary,falseto resolve it based on the current working directory. On Unix-like systems,truealso enables interpreters to work.systemandfork, this is always set totrueand cannot be configured.%PathExt%traversal.lookupPath: truenatively withexecve.options.fdsis an object where each numerical index corresponds to a descriptor to set in the child. This is not necessarily an array, though one could be passed. Default is{0: 0, 1: 1, 2: 2}to inherit those descriptors. Possible entry values:"close": explicitly close FD, cannot be used for FD 0/1/2"null": connect to the system null deviceMessagePortinstance (forkonly): open an IPC portMessagePorton the other side of the channel is also closed in the same way it is for workers where one end closes.fs/promisesfile handle,net.Socket, etc: Pass a given file descriptor directlyreadableStream: Expose a writable pipe and read from it using the given streamBufferReaderto read from buffers and stringswritableStream: Expose a readable pipe and write into it using the given streamBufferWriterto write into buffersoptions.fds.inherittotrueto inherit all FDs not specified inoptions.fds. Default isfalse, in which FDs 0/1/2 are opened to the null device and all others are closed.The return value is a Promise that settles when the child terminates.
exitCodeandsignalCodeproperties if it exited with any other code.pid = await handle.spawnedresolves with the PID on spawn and rejects on spawn error.Additional classes in
stream:writer = new BufferReader(target | max)stream.Writabletargetbuffer source to fill or amaxbyte lengthwriter.bytesWritten: Get the number of bytes writtenwriter.consume(): Reset the write state and return the previously written buffer data. If it's not writing to an external target, it's possible to avoid the buffer copy.writer.toString(encoding?)is sugar forwriter.consume().toString(encoding?)reader = new BufferWriter(source, encoding?)stream.Readablesourcestring (with optional encoding) or buffer source to read fromreader.bytesRead: Get the number of bytes readReadable.from(buffer | string)should return instances of this insteadduplex.reader(),duplex.writer(): Return the read or write half of a duplex stream, sharing the same internal stateAnd in
process:port = process.ipc(n=3): Get a (cached)MessagePortfor a given IPC descriptor, throwing if it's not a valid descriptor.result = await process.inspectFD(n)accepts an FD and returns its type and read/write state.{kind: "file", readable, writable}readableandwritablecan be determined viafcntl(F_GETFL, fd)on *nix (it's been in the POSIX standard for a couple decades)readableandwritablecan be determined via two calls toReOpenFileor one call toNtQueryObjectwith classObjectBasicInformation. (They say it can change, but it may be possible to get a stability promise out of them since the page hasn't been modified in over 6 years.){kind: "socket", readable, writable, type: "stream-client" | "stream-server" | "dgram"}{kind: "tty", readable, writable, rows, columns}ioctlsyscall on Linux{kind: "ipc", readable, writable}{kind: "unknown"}Things I'm intentionally leaving out:
options.serialization- it's always"advanced". This both brings it to close parity with otherMessagePort-related APIs, and it speeds up message sending since it's already of the correct input format.options.timeout- just dosignal: {SIGTERM: AbortSignal.timeout(ms)}.options.windowsHide- that behavior is just always on as that's what people would generally just expect.options.windowsVerbatimArguments- just usesystemand string concatenation."inherit"constants instdio- you can just use the descriptor numbers themselves for that."pipe"- use a passthrough stream for that.options.encoding- that's a per-descriptor setting now."close"event - it's better to track that per-stream anyways. Plus, it's one of those incredibly error-prone points.For a summary in the form of TypeScript definitions:
What alternatives have you considered?
I considered:
.ref(),.unref(),.pid,.wait(), and.raise(signal?). The main problem is this, for the common case, requiresawait start(...).then(h => h.wait()).handle.ipcas a single port. I don't see why one can't have multiple IPC ports, and it also simplifies the API and the implementation.