-
-
Notifications
You must be signed in to change notification settings - Fork 34.2k
Open
Labels
child_processIssues and PRs related to the child_process subsystem.Issues and PRs related to the child_process subsystem.feature requestIssues that request new features to be added to Node.js.Issues that request new features to be added to Node.js.
Description
What is the problem this feature will solve?
child_process is, in my experience, one of the most commonly wrapped APIs by far. It's especially common to wrap it in promise environments.
- Execa currently has 60 million weekly downloads.
cross-spawnhas over 50 million weekly downloads.- GitHub even created their own wrapper for use in their Actions product.
It all stems from a few major issues:
- Child processes have several one-time events, and none of them map cleanly to just one promise or even one promise sequence.
- It's non-trivial to create a wrapper that interops with streams more seamlessly.
- It's inherently error-prone to use directly, especially when it comes to shell script invocation on Windows.
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 channel
Options 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:URL
argsis an array of arguments to pass to the script or command and it defaults to the empty array.- Everything is fully escaped as needed, matching
cross-spawn's behavior.
- Everything is fully escaped as needed, matching
- Most of the usual
optionsproperties still work, with the same defaults:options.detachedoptions.cwdoptions.envoptions.argv0options.uidoptions.gid
options.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:[]
- Set
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.- For
systemandfork, this is always set totrueand cannot be configured. - On Windows, this also enables
%PathExt%traversal. - This is useful independently of shell access. Plus, Linux does
lookupPath: truenatively withexecve.
- For
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 port- These ports are transferred, so the internal code can just send directly to the linked port.
- When the child's IPC channel closes, the linked
MessagePorton the other side of the channel is also closed in the same way it is for workers where one end closes.
- Numeric FD,
fs/promisesfile handle,net.Socket, etc: Pass a given file descriptor directly readableStream: Expose a writable pipe and read from it using the given stream- Use
BufferReaderto read from buffers and strings
- Use
writableStream: Expose a readable pipe and write into it using the given stream- Use
BufferWriterto write into buffers
- Use
- Set
options.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.
- It resolves to an empty object if the command exited normally with a code of 0.
- It throws an error with
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)- Extends
stream.Writable - Pass either a
targetbuffer source to fill or amaxbyte length writer.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?)- It's recommended to implement this as an alternate stream mode to reduce memory usage and vastly improve performance.
- Extends
reader = new BufferWriter(source, encoding?)- Extends
stream.Readable - Pass a
sourcestring (with optional encoding) or buffer source to read from reader.bytesRead: Get the number of bytes readReadable.from(buffer | string)should return instances of this instead- It's recommended to implement this as an alternate stream mode to reduce memory usage and vastly improve performance.
- Extends
duplex.reader(),duplex.writer(): Return the read or write half of a duplex stream, sharing the same internal state
And in process:
port = process.ipc(n=3): Get a (cached)MessagePortfor a given IPC descriptor, throwing if it's not a valid descriptor.- Having multiple IPC ports can be useful for delimiting control messages from normal messages.
result = await process.inspectFD(n)accepts an FD and returns its type and read/write state.- File:
{kind: "file", readable, writable}- On *nix,
readableandwritablecan be determined viafcntl(F_GETFL, fd)on *nix (it's been in the POSIX standard for a couple decades) - On Windows,
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.)
- On *nix,
- Socket:
{kind: "socket", readable, writable, type: "stream-client" | "stream-server" | "dgram"} - Terminal stream:
{kind: "tty", readable, writable, rows, columns}- This can be tested and extracted in one
ioctlsyscall on Linux
- This can be tested and extracted in one
- IPC port:
{kind: "ipc", readable, writable} - Other:
{kind: "unknown"}- *nix pipes are reported as being of this type
- This is useful in conjunction with systemd for verifying that a given FD is actually open before attempting to use it.
- File:
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.- Per-FD
"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.- A global
"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:
declare module "child_process/promises" {
export interface WaitError extends Error {
exitCode: number | undefined
signalCode: string | undefined
}
type SignalSource =
| AbortSignal
| AsyncIterable<void>
type SignalMap = {[Signal in NodeJS.Signals]?: SignalSource}
type FdSource =
| "close"
| "null"
| MessagePort
| number
| import("node:net").Socket
| import("node:fs").FileHandle
| Readable | Writable
interface FdMap {
[fd: number]: FdSource
inherit?: boolean
}
interface Options {
detached?: boolean
cwd?: string
env?: Record<string, string>
argv0?: string
uid?: number
gid?: number
signal: SignalMap
ref?: boolean
pathLookup?: boolean
execPath?: string
execArgv?: string
fds: FdMap
}
interface ProcessHandle extends Promise<void> {
readonly spawned: Promise<number>
}
export function exec(command: string | URL, options: Options): ProcessHandle
export function exec(command: string | URL, args?: string[], options?: Options): ProcessHandle
export function system(command: string, options: Options): ProcessHandle
export function system(command: string, args?: string[], options?: Options): ProcessHandle
export function fork(command: string | URL, options: Options): ProcessHandle
export function fork(command: string | URL, args?: string[], options?: Options): ProcessHandle
}
declare module "stream" {
declare class BufferReader extends Readable {
constructor(source: BufferSource)
constructor(source: string, encoding?: NodeJS.BufferEncoding)
readonly bytesRead: number
}
declare class BufferWriter extends Writable {
constructor(target: BufferSource)
constructor(maxBytes: numbers)
readonly bytesWritten: number
consume(): Buffer
toString(encoding?: NodeJS.BufferEncoding): string
}
interface Duplex {
reader(): Readable
writer(): Writable
}
}
declare module "process" {
export type InspectFDResult =
| {kind: "file", readable: boolean, writable: boolean}
| {kind: "socket", readable: true, writable: true, type: "stream-client" | "stream-server" | "dgram"}
| {kind: "tty", readable: boolean, writable: boolean, rows: number, columns: number}
| {kind: "ipc", readable: false, writable: false}
| {kind: "unknown", readable: false, writable: false}
export function ipc(fd?: number): MessagePort
export function inspectFD(fd?: number): Promise<InspectFDResult>
}What alternatives have you considered?
I considered:
- Simple handles returned from a promise that resolves on spawn. It could have methods are
.ref(),.unref(),.pid,.wait(), and.raise(signal?). The main problem is this, for the common case, requiresawait start(...).then(h => h.wait()). - Captuing stderr in the error message. You may want to pass it through (very common), and it could be extremely long. There are ways to work around this, but it's simpler to just not capture it.
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.- Something like Execa. This is just too complicated to justify the effort.
- The status quo. It's consistently very awkward, hence the feature request.
slavarazum, danielbayley, fungaren, azrdn, stefnotch and 5 morebenjamingr and tonivj5
Metadata
Metadata
Assignees
Labels
child_processIssues and PRs related to the child_process subsystem.Issues and PRs related to the child_process subsystem.feature requestIssues that request new features to be added to Node.js.Issues that request new features to be added to Node.js.
Type
Projects
Status
Awaiting Triage