mirror of
https://github.com/denoland/deno.git
synced 2024-11-29 16:30:56 -05:00
1445 lines
36 KiB
TypeScript
1445 lines
36 KiB
TypeScript
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
|
|
|
|
// This module implements 'child_process' module of Node.JS API.
|
|
// ref: https://nodejs.org/api/child_process.html
|
|
|
|
// TODO(petamoriken): enable prefer-primordials for node polyfills
|
|
// deno-lint-ignore-file prefer-primordials
|
|
|
|
import { core, internals } from "ext:core/mod.js";
|
|
import {
|
|
op_node_ipc_read,
|
|
op_node_ipc_ref,
|
|
op_node_ipc_unref,
|
|
op_node_ipc_write,
|
|
} from "ext:core/ops";
|
|
import {
|
|
ArrayIsArray,
|
|
ArrayPrototypeFilter,
|
|
ArrayPrototypeJoin,
|
|
ArrayPrototypePush,
|
|
ArrayPrototypeSlice,
|
|
ArrayPrototypeSort,
|
|
ArrayPrototypeUnshift,
|
|
ObjectHasOwn,
|
|
StringPrototypeStartsWith,
|
|
StringPrototypeToUpperCase,
|
|
} from "ext:deno_node/internal/primordials.mjs";
|
|
import { assert } from "ext:deno_node/_util/asserts.ts";
|
|
import { EventEmitter } from "node:events";
|
|
import { os } from "ext:deno_node/internal_binding/constants.ts";
|
|
import { notImplemented } from "ext:deno_node/_utils.ts";
|
|
import { Readable, Stream, Writable } from "node:stream";
|
|
import { isWindows } from "ext:deno_node/_util/os.ts";
|
|
import { nextTick } from "ext:deno_node/_next_tick.ts";
|
|
import {
|
|
AbortError,
|
|
ERR_INVALID_ARG_TYPE,
|
|
ERR_INVALID_ARG_VALUE,
|
|
ERR_IPC_CHANNEL_CLOSED,
|
|
ERR_UNKNOWN_SIGNAL,
|
|
} from "ext:deno_node/internal/errors.ts";
|
|
import { Buffer } from "node:buffer";
|
|
import { errnoException } from "ext:deno_node/internal/errors.ts";
|
|
import { ErrnoException } from "ext:deno_node/_global.d.ts";
|
|
import { codeMap } from "ext:deno_node/internal_binding/uv.ts";
|
|
import {
|
|
isInt32,
|
|
validateBoolean,
|
|
validateObject,
|
|
validateString,
|
|
} from "ext:deno_node/internal/validators.mjs";
|
|
import { kEmptyObject } from "ext:deno_node/internal/util.mjs";
|
|
import { getValidatedPath } from "ext:deno_node/internal/fs/utils.mjs";
|
|
import process from "node:process";
|
|
import { StringPrototypeSlice } from "ext:deno_node/internal/primordials.mjs";
|
|
import { StreamBase } from "ext:deno_node/internal_binding/stream_wrap.ts";
|
|
import { Pipe, socketType } from "ext:deno_node/internal_binding/pipe_wrap.ts";
|
|
import { Socket } from "node:net";
|
|
import { kDetached, kExtraStdio, kIpc } from "ext:runtime/40_process.js";
|
|
|
|
export function mapValues<T, O>(
|
|
record: Readonly<Record<string, T>>,
|
|
transformer: (value: T) => O,
|
|
): Record<string, O> {
|
|
const ret: Record<string, O> = {};
|
|
const entries = Object.entries(record);
|
|
|
|
for (const [key, value] of entries) {
|
|
if (typeof value === "undefined") {
|
|
continue;
|
|
}
|
|
if (value === null) {
|
|
continue;
|
|
}
|
|
|
|
const mappedValue = transformer(value);
|
|
|
|
ret[key] = mappedValue;
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
type NodeStdio = "pipe" | "overlapped" | "ignore" | "inherit" | "ipc";
|
|
type DenoStdio = "inherit" | "piped" | "null";
|
|
|
|
export function stdioStringToArray(
|
|
stdio: NodeStdio,
|
|
channel: NodeStdio | number,
|
|
) {
|
|
const options: (NodeStdio | number)[] = [];
|
|
|
|
switch (stdio) {
|
|
case "ignore":
|
|
case "overlapped":
|
|
case "pipe":
|
|
options.push(stdio, stdio, stdio);
|
|
break;
|
|
case "inherit":
|
|
options.push(stdio, stdio, stdio);
|
|
break;
|
|
default:
|
|
throw new ERR_INVALID_ARG_VALUE("stdio", stdio);
|
|
}
|
|
|
|
if (channel) options.push(channel);
|
|
|
|
return options;
|
|
}
|
|
|
|
const kClosesNeeded = Symbol("_closesNeeded");
|
|
const kClosesReceived = Symbol("_closesReceived");
|
|
const kCanDisconnect = Symbol("_canDisconnect");
|
|
|
|
// We only want to emit a close event for the child process when all of
|
|
// the writable streams have closed. The value of `child[kClosesNeeded]` should be 1 +
|
|
// the number of opened writable streams (note this excludes `stdin`).
|
|
function maybeClose(child: ChildProcess) {
|
|
child[kClosesReceived]++;
|
|
if (child[kClosesNeeded] === child[kClosesReceived]) {
|
|
child.emit("close", child.exitCode, child.signalCode);
|
|
}
|
|
}
|
|
|
|
function flushStdio(subprocess: ChildProcess) {
|
|
const stdio = subprocess.stdio;
|
|
|
|
if (stdio == null) return;
|
|
|
|
for (let i = 0; i < stdio.length; i++) {
|
|
const stream = stdio[i];
|
|
if (!stream || !stream.readable) {
|
|
continue;
|
|
}
|
|
stream.resume();
|
|
}
|
|
}
|
|
|
|
// Wraps a resource in a class that implements
|
|
// StreamBase, so it can be used with node streams
|
|
class StreamResource implements StreamBase {
|
|
#rid: number;
|
|
constructor(rid: number) {
|
|
this.#rid = rid;
|
|
}
|
|
close(): void {
|
|
core.close(this.#rid);
|
|
}
|
|
async read(p: Uint8Array): Promise<number | null> {
|
|
const readPromise = core.read(this.#rid, p);
|
|
core.unrefOpPromise(readPromise);
|
|
const nread = await readPromise;
|
|
return nread > 0 ? nread : null;
|
|
}
|
|
ref(): void {
|
|
return;
|
|
}
|
|
unref(): void {
|
|
return;
|
|
}
|
|
write(p: Uint8Array): Promise<number> {
|
|
return core.write(this.#rid, p);
|
|
}
|
|
}
|
|
|
|
export class ChildProcess extends EventEmitter {
|
|
/**
|
|
* The exit code of the child process. This property will be `null` until the child process exits.
|
|
*/
|
|
exitCode: number | null = null;
|
|
|
|
/**
|
|
* This property is set to `true` after `kill()` is called.
|
|
*/
|
|
killed = false;
|
|
|
|
/**
|
|
* The PID of this child process.
|
|
*/
|
|
pid!: number;
|
|
|
|
/**
|
|
* The signal received by this child process.
|
|
*/
|
|
signalCode: string | null = null;
|
|
|
|
/**
|
|
* Command line arguments given to this child process.
|
|
*/
|
|
spawnargs: string[];
|
|
|
|
/**
|
|
* The executable file name of this child process.
|
|
*/
|
|
spawnfile: string;
|
|
|
|
/**
|
|
* This property represents the child process's stdin.
|
|
*/
|
|
stdin: Writable | null = null;
|
|
|
|
/**
|
|
* This property represents the child process's stdout.
|
|
*/
|
|
stdout: Readable | null = null;
|
|
|
|
/**
|
|
* This property represents the child process's stderr.
|
|
*/
|
|
stderr: Readable | null = null;
|
|
|
|
/**
|
|
* Pipes to this child process.
|
|
*/
|
|
stdio: [Writable | null, Readable | null, Readable | null] = [
|
|
null,
|
|
null,
|
|
null,
|
|
];
|
|
|
|
disconnect?: () => void;
|
|
|
|
#process!: Deno.ChildProcess;
|
|
#spawned = Promise.withResolvers<void>();
|
|
[kClosesNeeded] = 1;
|
|
[kClosesReceived] = 0;
|
|
[kCanDisconnect] = false;
|
|
|
|
constructor(
|
|
command: string,
|
|
args?: string[],
|
|
options?: ChildProcessOptions,
|
|
) {
|
|
super();
|
|
|
|
const {
|
|
env = {},
|
|
stdio = ["pipe", "pipe", "pipe"],
|
|
cwd,
|
|
shell = false,
|
|
signal,
|
|
windowsVerbatimArguments = false,
|
|
detached,
|
|
} = options || {};
|
|
const normalizedStdio = normalizeStdioOption(stdio);
|
|
const [
|
|
stdin = "pipe",
|
|
stdout = "pipe",
|
|
stderr = "pipe",
|
|
...extraStdio
|
|
] = normalizedStdio;
|
|
const [cmd, cmdArgs] = buildCommand(
|
|
command,
|
|
args || [],
|
|
shell,
|
|
);
|
|
this.spawnfile = cmd;
|
|
this.spawnargs = [cmd, ...cmdArgs];
|
|
|
|
const ipc = normalizedStdio.indexOf("ipc");
|
|
|
|
const extraStdioOffset = 3; // stdin, stdout, stderr
|
|
|
|
const extraStdioNormalized: DenoStdio[] = [];
|
|
for (let i = 0; i < extraStdio.length; i++) {
|
|
const fd = i + extraStdioOffset;
|
|
if (fd === ipc) extraStdioNormalized.push("null");
|
|
extraStdioNormalized.push(toDenoStdio(extraStdio[i]));
|
|
}
|
|
|
|
const stringEnv = mapValues(env, (value) => value.toString());
|
|
try {
|
|
this.#process = new Deno.Command(cmd, {
|
|
args: cmdArgs,
|
|
cwd,
|
|
env: stringEnv,
|
|
stdin: toDenoStdio(stdin),
|
|
stdout: toDenoStdio(stdout),
|
|
stderr: toDenoStdio(stderr),
|
|
windowsRawArguments: windowsVerbatimArguments,
|
|
[kIpc]: ipc, // internal
|
|
[kExtraStdio]: extraStdioNormalized,
|
|
[kDetached]: detached,
|
|
}).spawn();
|
|
this.pid = this.#process.pid;
|
|
|
|
if (stdin === "pipe") {
|
|
assert(this.#process.stdin);
|
|
this.stdin = Writable.fromWeb(this.#process.stdin);
|
|
}
|
|
|
|
if (stdin instanceof Stream) {
|
|
this.stdin = stdin;
|
|
}
|
|
if (stdout instanceof Stream) {
|
|
this.stdout = stdout;
|
|
}
|
|
if (stderr instanceof Stream) {
|
|
this.stderr = stderr;
|
|
}
|
|
|
|
if (stdout === "pipe") {
|
|
assert(this.#process.stdout);
|
|
this[kClosesNeeded]++;
|
|
this.stdout = Readable.fromWeb(this.#process.stdout);
|
|
this.stdout.on("close", () => {
|
|
maybeClose(this);
|
|
});
|
|
}
|
|
|
|
if (stderr === "pipe") {
|
|
assert(this.#process.stderr);
|
|
this[kClosesNeeded]++;
|
|
this.stderr = Readable.fromWeb(this.#process.stderr);
|
|
this.stderr.on("close", () => {
|
|
maybeClose(this);
|
|
});
|
|
}
|
|
|
|
this.stdio[0] = this.stdin;
|
|
this.stdio[1] = this.stdout;
|
|
this.stdio[2] = this.stderr;
|
|
|
|
if (ipc >= 0) {
|
|
this.stdio[ipc] = null;
|
|
}
|
|
|
|
const pipeRids = internals.getExtraPipeRids(this.#process);
|
|
for (let i = 0; i < pipeRids.length; i++) {
|
|
const rid: number | null = pipeRids[i];
|
|
const fd = i + extraStdioOffset;
|
|
if (rid) {
|
|
this[kClosesNeeded]++;
|
|
this.stdio[fd] = new Socket(
|
|
{
|
|
handle: new Pipe(
|
|
socketType.IPC,
|
|
new StreamResource(rid),
|
|
),
|
|
// deno-lint-ignore no-explicit-any
|
|
} as any,
|
|
);
|
|
this.stdio[fd]?.on("close", () => {
|
|
maybeClose(this);
|
|
});
|
|
}
|
|
}
|
|
|
|
nextTick(() => {
|
|
this.emit("spawn");
|
|
this.#spawned.resolve();
|
|
});
|
|
|
|
if (signal) {
|
|
const onAbortListener = () => {
|
|
try {
|
|
if (this.kill("SIGKILL")) {
|
|
this.emit("error", new AbortError());
|
|
}
|
|
} catch (err) {
|
|
this.emit("error", err);
|
|
}
|
|
};
|
|
if (signal.aborted) {
|
|
nextTick(onAbortListener);
|
|
} else {
|
|
signal.addEventListener("abort", onAbortListener, { once: true });
|
|
this.addListener(
|
|
"exit",
|
|
() => signal.removeEventListener("abort", onAbortListener),
|
|
);
|
|
}
|
|
}
|
|
|
|
const pipeRid = internals.getIpcPipeRid(this.#process);
|
|
if (typeof pipeRid == "number") {
|
|
setupChannel(this, pipeRid);
|
|
this[kClosesNeeded]++;
|
|
this.on("disconnect", () => {
|
|
maybeClose(this);
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
const status = await this.#process.status;
|
|
this.exitCode = status.code;
|
|
this.#spawned.promise.then(async () => {
|
|
const exitCode = this.signalCode == null ? this.exitCode : null;
|
|
const signalCode = this.signalCode == null ? null : this.signalCode;
|
|
// The 'exit' and 'close' events must be emitted after the 'spawn' event.
|
|
this.emit("exit", exitCode, signalCode);
|
|
await this.#_waitForChildStreamsToClose();
|
|
this.#closePipes();
|
|
nextTick(flushStdio, this);
|
|
maybeClose(this);
|
|
});
|
|
})();
|
|
} catch (err) {
|
|
let e = err;
|
|
if (e instanceof Deno.errors.NotFound) {
|
|
e = _createSpawnSyncError("ENOENT", command, args);
|
|
}
|
|
this.#_handleError(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param signal NOTE: this parameter is not yet implemented.
|
|
*/
|
|
kill(signal?: number | string): boolean {
|
|
if (this.killed) {
|
|
return this.killed;
|
|
}
|
|
|
|
const denoSignal = signal == null ? "SIGTERM" : toDenoSignal(signal);
|
|
this.#closePipes();
|
|
try {
|
|
this.#process.kill(denoSignal);
|
|
} catch (err) {
|
|
const alreadyClosed = err instanceof TypeError ||
|
|
err instanceof Deno.errors.PermissionDenied;
|
|
if (!alreadyClosed) {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/* Cancel any pending IPC I/O */
|
|
if (this[kCanDisconnect]) {
|
|
this.disconnect?.();
|
|
}
|
|
|
|
this.killed = true;
|
|
this.signalCode = denoSignal;
|
|
return this.killed;
|
|
}
|
|
|
|
ref() {
|
|
this.#process.ref();
|
|
}
|
|
|
|
unref() {
|
|
this.#process.unref();
|
|
}
|
|
|
|
async #_waitForChildStreamsToClose() {
|
|
const promises = [] as Array<Promise<void>>;
|
|
// Don't close parent process stdin if that's passed through
|
|
if (this.stdin && !this.stdin.destroyed && this.stdin !== process.stdin) {
|
|
assert(this.stdin);
|
|
this.stdin.destroy();
|
|
promises.push(waitForStreamToClose(this.stdin));
|
|
}
|
|
// Only readable streams need to be closed
|
|
if (
|
|
this.stdout && !this.stdout.destroyed && this.stdout instanceof Readable
|
|
) {
|
|
promises.push(waitForReadableToClose(this.stdout));
|
|
}
|
|
// Only readable streams need to be closed
|
|
if (
|
|
this.stderr && !this.stderr.destroyed && this.stderr instanceof Readable
|
|
) {
|
|
promises.push(waitForReadableToClose(this.stderr));
|
|
}
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
#_handleError(err: unknown) {
|
|
nextTick(() => {
|
|
this.emit("error", err); // TODO(uki00a) Convert `err` into nodejs's `SystemError` class.
|
|
});
|
|
}
|
|
|
|
#closePipes() {
|
|
if (this.stdin) {
|
|
assert(this.stdin);
|
|
this.stdin.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
const supportedNodeStdioTypes: NodeStdio[] = [
|
|
"pipe",
|
|
"ignore",
|
|
"inherit",
|
|
"ipc",
|
|
];
|
|
function toDenoStdio(
|
|
pipe: NodeStdio | number | Stream | null | undefined,
|
|
): DenoStdio {
|
|
if (pipe instanceof Stream) {
|
|
return "inherit";
|
|
}
|
|
if (typeof pipe === "number") {
|
|
/* Assume it's a rid returned by fs APIs */
|
|
return pipe;
|
|
}
|
|
|
|
if (
|
|
!supportedNodeStdioTypes.includes(pipe as NodeStdio)
|
|
) {
|
|
notImplemented(`toDenoStdio pipe=${typeof pipe} (${pipe})`);
|
|
}
|
|
switch (pipe) {
|
|
case "pipe":
|
|
case undefined:
|
|
case null:
|
|
return "piped";
|
|
case "ignore":
|
|
return "null";
|
|
case "inherit":
|
|
return "inherit";
|
|
case "ipc":
|
|
return "ipc_for_internal_use";
|
|
default:
|
|
notImplemented(`toDenoStdio pipe=${typeof pipe} (${pipe})`);
|
|
}
|
|
}
|
|
|
|
function toDenoSignal(signal: number | string): Deno.Signal {
|
|
if (typeof signal === "number") {
|
|
for (const name of keys(os.signals)) {
|
|
if (os.signals[name] === signal) {
|
|
return name as Deno.Signal;
|
|
}
|
|
}
|
|
throw new ERR_UNKNOWN_SIGNAL(String(signal));
|
|
}
|
|
|
|
const denoSignal = signal as Deno.Signal;
|
|
if (denoSignal in os.signals) {
|
|
return denoSignal;
|
|
}
|
|
throw new ERR_UNKNOWN_SIGNAL(signal);
|
|
}
|
|
|
|
function keys<T extends Record<string, unknown>>(object: T): Array<keyof T> {
|
|
return Object.keys(object);
|
|
}
|
|
|
|
export interface ChildProcessOptions {
|
|
/**
|
|
* Current working directory of the child process.
|
|
*/
|
|
cwd?: string | URL;
|
|
|
|
/**
|
|
* Environment variables passed to the child process.
|
|
*/
|
|
env?: Record<string, string | number | boolean>;
|
|
|
|
/**
|
|
* This option defines child process's stdio configuration.
|
|
* @see https://nodejs.org/api/child_process.html#child_process_options_stdio
|
|
*/
|
|
stdio?: Array<NodeStdio | number | Stream | null | undefined> | NodeStdio;
|
|
|
|
/**
|
|
* Whether to spawn the process in a detached state.
|
|
*/
|
|
detached?: boolean;
|
|
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
uid?: number;
|
|
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
gid?: number;
|
|
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
argv0?: string;
|
|
|
|
/**
|
|
* * If this option is `true`, run the command in the shell.
|
|
* * If this option is a string, run the command in the specified shell.
|
|
*/
|
|
shell?: string | boolean;
|
|
|
|
/**
|
|
* Allows aborting the child process using an AbortSignal.
|
|
*/
|
|
signal?: AbortSignal;
|
|
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
serialization?: "json" | "advanced";
|
|
|
|
/** No quoting or escaping of arguments is done on Windows. Ignored on Unix.
|
|
* Default: false. */
|
|
windowsVerbatimArguments?: boolean;
|
|
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
windowsHide?: boolean;
|
|
}
|
|
|
|
function copyProcessEnvToEnv(
|
|
env: Record<string, string | number | boolean | undefined>,
|
|
name: string,
|
|
optionEnv?: Record<string, string | number | boolean>,
|
|
) {
|
|
if (
|
|
Deno.env.get(name) &&
|
|
(!optionEnv ||
|
|
!ObjectHasOwn(optionEnv, name))
|
|
) {
|
|
env[name] = Deno.env.get(name);
|
|
}
|
|
}
|
|
|
|
function normalizeStdioOption(
|
|
stdio: Array<NodeStdio | number | null | undefined | Stream> | NodeStdio = [
|
|
"pipe",
|
|
"pipe",
|
|
"pipe",
|
|
],
|
|
): [
|
|
Stream | NodeStdio | number,
|
|
Stream | NodeStdio | number,
|
|
Stream | NodeStdio | number,
|
|
...Array<Stream | NodeStdio | number>,
|
|
] {
|
|
if (Array.isArray(stdio)) {
|
|
// `[0, 1, 2]` is equivalent to `"inherit"`
|
|
if (
|
|
stdio.length === 3 &&
|
|
(stdio[0] === 0 && stdio[1] === 1 && stdio[2] === 2)
|
|
) {
|
|
return ["inherit", "inherit", "inherit"];
|
|
}
|
|
|
|
// `[null, null, null]` is equivalent to `"pipe"
|
|
if (
|
|
stdio.length === 3 &&
|
|
stdio[0] === null || stdio[1] === null || stdio[2] === null
|
|
) {
|
|
return ["pipe", "pipe", "pipe"];
|
|
}
|
|
|
|
// At least 3 stdio must be created to match node
|
|
while (stdio.length < 3) {
|
|
ArrayPrototypePush(stdio, undefined);
|
|
}
|
|
return stdio;
|
|
} else {
|
|
switch (stdio) {
|
|
case "overlapped":
|
|
if (isWindows) {
|
|
notImplemented("normalizeStdioOption overlapped (on windows)");
|
|
}
|
|
// 'overlapped' is same as 'piped' on non Windows system.
|
|
return ["pipe", "pipe", "pipe"];
|
|
case "pipe":
|
|
return ["pipe", "pipe", "pipe"];
|
|
case "inherit":
|
|
return ["inherit", "inherit", "inherit"];
|
|
case "ignore":
|
|
return ["ignore", "ignore", "ignore"];
|
|
default:
|
|
notImplemented(`normalizeStdioOption stdio=${typeof stdio} (${stdio})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function normalizeSpawnArguments(
|
|
file: string,
|
|
args: string[],
|
|
options: SpawnOptions & SpawnSyncOptions,
|
|
) {
|
|
validateString(file, "file");
|
|
|
|
if (file.length === 0) {
|
|
throw new ERR_INVALID_ARG_VALUE("file", file, "cannot be empty");
|
|
}
|
|
|
|
if (ArrayIsArray(args)) {
|
|
args = ArrayPrototypeSlice(args);
|
|
} else if (args == null) {
|
|
args = [];
|
|
} else if (typeof args !== "object") {
|
|
throw new ERR_INVALID_ARG_TYPE("args", "object", args);
|
|
} else {
|
|
options = args;
|
|
args = [];
|
|
}
|
|
|
|
if (options === undefined) {
|
|
options = kEmptyObject;
|
|
} else {
|
|
validateObject(options, "options");
|
|
}
|
|
|
|
let cwd = options.cwd;
|
|
|
|
// Validate the cwd, if present.
|
|
if (cwd != null) {
|
|
cwd = getValidatedPath(cwd, "options.cwd") as string;
|
|
}
|
|
|
|
// Validate detached, if present.
|
|
if (options.detached != null) {
|
|
validateBoolean(options.detached, "options.detached");
|
|
}
|
|
|
|
// Validate the uid, if present.
|
|
if (options.uid != null && !isInt32(options.uid)) {
|
|
throw new ERR_INVALID_ARG_TYPE("options.uid", "int32", options.uid);
|
|
}
|
|
|
|
// Validate the gid, if present.
|
|
if (options.gid != null && !isInt32(options.gid)) {
|
|
throw new ERR_INVALID_ARG_TYPE("options.gid", "int32", options.gid);
|
|
}
|
|
|
|
// Validate the shell, if present.
|
|
if (
|
|
options.shell != null &&
|
|
typeof options.shell !== "boolean" &&
|
|
typeof options.shell !== "string"
|
|
) {
|
|
throw new ERR_INVALID_ARG_TYPE(
|
|
"options.shell",
|
|
["boolean", "string"],
|
|
options.shell,
|
|
);
|
|
}
|
|
|
|
// Validate argv0, if present.
|
|
if (options.argv0 != null) {
|
|
validateString(options.argv0, "options.argv0");
|
|
}
|
|
|
|
// Validate windowsHide, if present.
|
|
if (options.windowsHide != null) {
|
|
validateBoolean(options.windowsHide, "options.windowsHide");
|
|
}
|
|
|
|
// Validate windowsVerbatimArguments, if present.
|
|
let { windowsVerbatimArguments } = options;
|
|
if (windowsVerbatimArguments != null) {
|
|
validateBoolean(
|
|
windowsVerbatimArguments,
|
|
"options.windowsVerbatimArguments",
|
|
);
|
|
}
|
|
|
|
if (options.shell) {
|
|
const command = ArrayPrototypeJoin([file, ...args], " ");
|
|
// Set the shell, switches, and commands.
|
|
if (process.platform === "win32") {
|
|
if (typeof options.shell === "string") {
|
|
file = options.shell;
|
|
} else {
|
|
file = Deno.env.get("comspec") || "cmd.exe";
|
|
}
|
|
// '/d /s /c' is used only for cmd.exe.
|
|
if (/^(?:.*\\)?cmd(?:\.exe)?$/i.exec(file) !== null) {
|
|
args = ["/d", "/s", "/c", `"${command}"`];
|
|
windowsVerbatimArguments = true;
|
|
} else {
|
|
args = ["-c", command];
|
|
}
|
|
} else {
|
|
/** TODO: add Android condition */
|
|
if (typeof options.shell === "string") {
|
|
file = options.shell;
|
|
} else {
|
|
file = "/bin/sh";
|
|
}
|
|
args = ["-c", command];
|
|
}
|
|
}
|
|
|
|
if (typeof options.argv0 === "string") {
|
|
ArrayPrototypeUnshift(args, options.argv0);
|
|
} else {
|
|
ArrayPrototypeUnshift(args, file);
|
|
}
|
|
|
|
const env = options.env || Deno.env.toObject();
|
|
const envPairs: string[][] = [];
|
|
|
|
// process.env.NODE_V8_COVERAGE always propagates, making it possible to
|
|
// collect coverage for programs that spawn with white-listed environment.
|
|
copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env);
|
|
|
|
/** TODO: add `isZOS` condition */
|
|
|
|
let envKeys: string[] = [];
|
|
// Prototype values are intentionally included.
|
|
for (const key in env) {
|
|
if (Object.hasOwn(env, key)) {
|
|
ArrayPrototypePush(envKeys, key);
|
|
}
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
// On Windows env keys are case insensitive. Filter out duplicates,
|
|
// keeping only the first one (in lexicographic order)
|
|
/** TODO: implement SafeSet and makeSafe */
|
|
const sawKey = new Set();
|
|
envKeys = ArrayPrototypeFilter(
|
|
ArrayPrototypeSort(envKeys),
|
|
(key: string) => {
|
|
const uppercaseKey = StringPrototypeToUpperCase(key);
|
|
if (sawKey.has(uppercaseKey)) {
|
|
return false;
|
|
}
|
|
sawKey.add(uppercaseKey);
|
|
return true;
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const key of envKeys) {
|
|
const value = env[key];
|
|
if (value !== undefined) {
|
|
ArrayPrototypePush(envPairs, `${key}=${value}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
// Make a shallow copy so we don't clobber the user's options object.
|
|
...options,
|
|
args,
|
|
cwd,
|
|
detached: !!options.detached,
|
|
envPairs,
|
|
file,
|
|
windowsHide: !!options.windowsHide,
|
|
windowsVerbatimArguments: !!windowsVerbatimArguments,
|
|
};
|
|
}
|
|
|
|
function waitForReadableToClose(readable: Readable) {
|
|
readable.resume(); // Ensure buffered data will be consumed.
|
|
return waitForStreamToClose(readable as unknown as Stream);
|
|
}
|
|
|
|
function waitForStreamToClose(stream: Stream) {
|
|
const deferred = Promise.withResolvers<void>();
|
|
const cleanup = () => {
|
|
stream.removeListener("close", onClose);
|
|
stream.removeListener("error", onError);
|
|
};
|
|
const onClose = () => {
|
|
cleanup();
|
|
deferred.resolve();
|
|
};
|
|
const onError = (err: Error) => {
|
|
cleanup();
|
|
deferred.reject(err);
|
|
};
|
|
stream.once("close", onClose);
|
|
stream.once("error", onError);
|
|
return deferred.promise;
|
|
}
|
|
|
|
/**
|
|
* This function is based on https://github.com/nodejs/node/blob/fc6426ccc4b4cb73076356fb6dbf46a28953af01/lib/child_process.js#L504-L528.
|
|
* Copyright Joyent, Inc. and other Node contributors. All rights reserved. MIT license.
|
|
*/
|
|
function buildCommand(
|
|
file: string,
|
|
args: string[],
|
|
shell: string | boolean,
|
|
): [string, string[]] {
|
|
if (file === Deno.execPath()) {
|
|
// The user is trying to spawn another Deno process as Node.js.
|
|
args = toDenoArgs(args);
|
|
}
|
|
|
|
if (shell) {
|
|
const command = [file, ...args].join(" ");
|
|
|
|
// Set the shell, switches, and commands.
|
|
if (isWindows) {
|
|
if (typeof shell === "string") {
|
|
file = shell;
|
|
} else {
|
|
file = Deno.env.get("comspec") || "cmd.exe";
|
|
}
|
|
// '/d /s /c' is used only for cmd.exe.
|
|
if (/^(?:.*\\)?cmd(?:\.exe)?$/i.test(file)) {
|
|
args = ["/d", "/s", "/c", `"${command}"`];
|
|
} else {
|
|
args = ["-c", command];
|
|
}
|
|
} else {
|
|
if (typeof shell === "string") {
|
|
file = shell;
|
|
} else {
|
|
file = "/bin/sh";
|
|
}
|
|
args = ["-c", command];
|
|
}
|
|
}
|
|
return [file, args];
|
|
}
|
|
|
|
function _createSpawnSyncError(
|
|
status: string,
|
|
command: string,
|
|
args: string[] = [],
|
|
): ErrnoException {
|
|
const error = errnoException(
|
|
codeMap.get(status),
|
|
"spawnSync " + command,
|
|
);
|
|
error.path = command;
|
|
error.spawnargs = args;
|
|
return error;
|
|
}
|
|
|
|
export interface SpawnOptions extends ChildProcessOptions {
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
timeout?: number;
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
killSignal?: string;
|
|
}
|
|
|
|
export interface SpawnSyncOptions extends
|
|
Pick<
|
|
ChildProcessOptions,
|
|
| "cwd"
|
|
| "env"
|
|
| "argv0"
|
|
| "stdio"
|
|
| "uid"
|
|
| "gid"
|
|
| "shell"
|
|
| "windowsVerbatimArguments"
|
|
| "windowsHide"
|
|
> {
|
|
input?: string | Buffer | DataView;
|
|
timeout?: number;
|
|
maxBuffer?: number;
|
|
encoding?: string;
|
|
/**
|
|
* NOTE: This option is not yet implemented.
|
|
*/
|
|
killSignal?: string;
|
|
}
|
|
|
|
export interface SpawnSyncResult {
|
|
pid?: number;
|
|
output?: [string | null, string | Buffer | null, string | Buffer | null];
|
|
stdout?: Buffer | string | null;
|
|
stderr?: Buffer | string | null;
|
|
status?: number | null;
|
|
signal?: string | null;
|
|
error?: Error;
|
|
}
|
|
|
|
function parseSpawnSyncOutputStreams(
|
|
output: Deno.CommandOutput,
|
|
name: "stdout" | "stderr",
|
|
): string | Buffer | null {
|
|
// new Deno.Command().outputSync() returns getters for stdout and stderr that throw when set
|
|
// to 'inherit'.
|
|
try {
|
|
return Buffer.from(output[name]) as string | Buffer;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function spawnSync(
|
|
command: string,
|
|
args: string[],
|
|
options: SpawnSyncOptions,
|
|
): SpawnSyncResult {
|
|
const {
|
|
env = Deno.env.toObject(),
|
|
stdio = ["pipe", "pipe", "pipe"],
|
|
shell = false,
|
|
cwd,
|
|
encoding,
|
|
uid,
|
|
gid,
|
|
maxBuffer,
|
|
windowsVerbatimArguments = false,
|
|
} = options;
|
|
const [
|
|
stdin_ = "pipe",
|
|
stdout_ = "pipe",
|
|
stderr_ = "pipe",
|
|
_channel, // TODO(kt3k): handle this correctly
|
|
] = normalizeStdioOption(stdio);
|
|
[command, args] = buildCommand(command, args ?? [], shell);
|
|
|
|
const result: SpawnSyncResult = {};
|
|
try {
|
|
const output = new Deno.Command(command, {
|
|
args,
|
|
cwd,
|
|
env: mapValues(env, (value) => value.toString()),
|
|
stdout: toDenoStdio(stdout_),
|
|
stderr: toDenoStdio(stderr_),
|
|
stdin: stdin_ == "inherit" ? "inherit" : "null",
|
|
uid,
|
|
gid,
|
|
windowsRawArguments: windowsVerbatimArguments,
|
|
}).outputSync();
|
|
|
|
const status = output.signal ? null : output.code;
|
|
let stdout = parseSpawnSyncOutputStreams(output, "stdout");
|
|
let stderr = parseSpawnSyncOutputStreams(output, "stderr");
|
|
|
|
if (
|
|
(stdout && stdout.length > maxBuffer!) ||
|
|
(stderr && stderr.length > maxBuffer!)
|
|
) {
|
|
result.error = _createSpawnSyncError("ENOBUFS", command, args);
|
|
}
|
|
|
|
if (encoding && encoding !== "buffer") {
|
|
stdout = stdout && stdout.toString(encoding);
|
|
stderr = stderr && stderr.toString(encoding);
|
|
}
|
|
|
|
result.status = status;
|
|
result.signal = output.signal;
|
|
result.stdout = stdout;
|
|
result.stderr = stderr;
|
|
result.output = [output.signal, stdout, stderr];
|
|
} catch (err) {
|
|
if (err instanceof Deno.errors.NotFound) {
|
|
result.error = _createSpawnSyncError("ENOENT", command, args);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// These are Node.js CLI flags that expect a value. It's necessary to
|
|
// understand these flags in order to properly replace flags passed to the
|
|
// child process. For example, -e is a Node flag for eval mode if it is part
|
|
// of process.execArgv. However, -e could also be an application flag if it is
|
|
// part of process.execv instead. We only want to process execArgv flags.
|
|
const kLongArgType = 1;
|
|
const kShortArgType = 2;
|
|
const kLongArg = { type: kLongArgType };
|
|
const kShortArg = { type: kShortArgType };
|
|
const kNodeFlagsMap = new Map([
|
|
["--build-snapshot", kLongArg],
|
|
["-c", kShortArg],
|
|
["--check", kLongArg],
|
|
["-C", kShortArg],
|
|
["--conditions", kLongArg],
|
|
["--cpu-prof-dir", kLongArg],
|
|
["--cpu-prof-interval", kLongArg],
|
|
["--cpu-prof-name", kLongArg],
|
|
["--diagnostic-dir", kLongArg],
|
|
["--disable-proto", kLongArg],
|
|
["--dns-result-order", kLongArg],
|
|
["-e", kShortArg],
|
|
["--eval", kLongArg],
|
|
["--experimental-loader", kLongArg],
|
|
["--experimental-policy", kLongArg],
|
|
["--experimental-specifier-resolution", kLongArg],
|
|
["--heapsnapshot-near-heap-limit", kLongArg],
|
|
["--heapsnapshot-signal", kLongArg],
|
|
["--heap-prof-dir", kLongArg],
|
|
["--heap-prof-interval", kLongArg],
|
|
["--heap-prof-name", kLongArg],
|
|
["--icu-data-dir", kLongArg],
|
|
["--input-type", kLongArg],
|
|
["--inspect-publish-uid", kLongArg],
|
|
["--max-http-header-size", kLongArg],
|
|
["--openssl-config", kLongArg],
|
|
["-p", kShortArg],
|
|
["--print", kLongArg],
|
|
["--policy-integrity", kLongArg],
|
|
["--prof-process", kLongArg],
|
|
["-r", kShortArg],
|
|
["--require", kLongArg],
|
|
["--redirect-warnings", kLongArg],
|
|
["--report-dir", kLongArg],
|
|
["--report-directory", kLongArg],
|
|
["--report-filename", kLongArg],
|
|
["--report-signal", kLongArg],
|
|
["--secure-heap", kLongArg],
|
|
["--secure-heap-min", kLongArg],
|
|
["--snapshot-blob", kLongArg],
|
|
["--title", kLongArg],
|
|
["--tls-cipher-list", kLongArg],
|
|
["--tls-keylog", kLongArg],
|
|
["--unhandled-rejections", kLongArg],
|
|
["--use-largepages", kLongArg],
|
|
["--v8-pool-size", kLongArg],
|
|
]);
|
|
const kDenoSubcommands = new Set([
|
|
"add",
|
|
"bench",
|
|
"cache",
|
|
"check",
|
|
"compile",
|
|
"completions",
|
|
"coverage",
|
|
"doc",
|
|
"eval",
|
|
"fmt",
|
|
"help",
|
|
"info",
|
|
"init",
|
|
"install",
|
|
"lint",
|
|
"lsp",
|
|
"publish",
|
|
"repl",
|
|
"run",
|
|
"tasks",
|
|
"test",
|
|
"types",
|
|
"uninstall",
|
|
"upgrade",
|
|
"vendor",
|
|
]);
|
|
|
|
function toDenoArgs(args: string[]): string[] {
|
|
if (args.length === 0) {
|
|
return args;
|
|
}
|
|
|
|
// Update this logic as more CLI arguments are mapped from Node to Deno.
|
|
const denoArgs: string[] = [];
|
|
let useRunArgs = true;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (arg.charAt(0) !== "-" || arg === "--") {
|
|
// Not a flag or no more arguments.
|
|
|
|
// If the arg is a Deno subcommand, then the child process is being
|
|
// spawned as Deno, not Deno in Node compat mode. In this case, bail out
|
|
// and return the original args.
|
|
if (kDenoSubcommands.has(arg)) {
|
|
return args;
|
|
}
|
|
|
|
// Copy of the rest of the arguments to the output.
|
|
for (let j = i; j < args.length; j++) {
|
|
denoArgs.push(args[j]);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// Something that looks like a flag was passed.
|
|
let flag = arg;
|
|
let flagInfo = kNodeFlagsMap.get(arg);
|
|
let isLongWithValue = false;
|
|
let flagValue;
|
|
|
|
if (flag === "--v8-options") {
|
|
// If --v8-options is passed, it should be replaced with --v8-flags="--help".
|
|
denoArgs.push("--v8-flags=--help");
|
|
continue;
|
|
}
|
|
|
|
if (flagInfo === undefined) {
|
|
// If the flag was not found, it's either not a known flag or it's a long
|
|
// flag containing an '='.
|
|
const splitAt = arg.indexOf("=");
|
|
|
|
if (splitAt !== -1) {
|
|
flag = arg.slice(0, splitAt);
|
|
flagInfo = kNodeFlagsMap.get(flag);
|
|
flagValue = arg.slice(splitAt + 1);
|
|
isLongWithValue = true;
|
|
}
|
|
}
|
|
|
|
if (flagInfo === undefined) {
|
|
// Not a known flag that expects a value. Just copy it to the output.
|
|
denoArgs.push(arg);
|
|
continue;
|
|
}
|
|
|
|
// This is a flag with a value. Get the value if we don't already have it.
|
|
if (flagValue === undefined) {
|
|
i++;
|
|
|
|
if (i >= args.length) {
|
|
// There was user error. There should be another arg for the value, but
|
|
// there isn't one. Just copy the arg to the output. It's not going
|
|
// to work anyway.
|
|
denoArgs.push(arg);
|
|
continue;
|
|
}
|
|
|
|
flagValue = args[i];
|
|
}
|
|
|
|
// Remap Node's eval flags to Deno.
|
|
if (flag === "-e" || flag === "--eval") {
|
|
denoArgs.push("eval", flagValue);
|
|
useRunArgs = false;
|
|
} else if (isLongWithValue) {
|
|
denoArgs.push(arg);
|
|
} else {
|
|
denoArgs.push(flag, flagValue);
|
|
}
|
|
}
|
|
|
|
if (useRunArgs) {
|
|
// -A is not ideal, but needed to propagate permissions.
|
|
denoArgs.unshift("run", "-A");
|
|
}
|
|
|
|
return denoArgs;
|
|
}
|
|
|
|
const kControlDisconnect = Symbol("kControlDisconnect");
|
|
const kPendingMessages = Symbol("kPendingMessages");
|
|
|
|
// controls refcounting for the IPC channel
|
|
class Control extends EventEmitter {
|
|
#channel: number;
|
|
#refs: number = 0;
|
|
#refExplicitlySet = false;
|
|
#connected = true;
|
|
[kPendingMessages] = [];
|
|
constructor(channel: number) {
|
|
super();
|
|
this.#channel = channel;
|
|
}
|
|
|
|
#ref() {
|
|
if (this.#connected) {
|
|
op_node_ipc_ref(this.#channel);
|
|
}
|
|
}
|
|
|
|
#unref() {
|
|
if (this.#connected) {
|
|
op_node_ipc_unref(this.#channel);
|
|
}
|
|
}
|
|
|
|
[kControlDisconnect]() {
|
|
this.#unref();
|
|
this.#connected = false;
|
|
}
|
|
|
|
refCounted() {
|
|
if (++this.#refs === 1 && !this.#refExplicitlySet) {
|
|
this.#ref();
|
|
}
|
|
}
|
|
|
|
unrefCounted() {
|
|
if (--this.#refs === 0 && !this.#refExplicitlySet) {
|
|
this.#unref();
|
|
this.emit("unref");
|
|
}
|
|
}
|
|
|
|
ref() {
|
|
this.#refExplicitlySet = true;
|
|
this.#ref();
|
|
}
|
|
|
|
unref() {
|
|
this.#refExplicitlySet = false;
|
|
this.#unref();
|
|
}
|
|
}
|
|
|
|
type InternalMessage = {
|
|
cmd: `NODE_${string}`;
|
|
};
|
|
|
|
// deno-lint-ignore no-explicit-any
|
|
function isInternal(msg: any): msg is InternalMessage {
|
|
if (msg && typeof msg === "object") {
|
|
const cmd = msg["cmd"];
|
|
if (typeof cmd === "string") {
|
|
return StringPrototypeStartsWith(cmd, "NODE_");
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function internalCmdName(msg: InternalMessage): string {
|
|
return StringPrototypeSlice(msg.cmd, 5);
|
|
}
|
|
|
|
// deno-lint-ignore no-explicit-any
|
|
export function setupChannel(target: any, ipc: number) {
|
|
const control = new Control(ipc);
|
|
target.channel = control;
|
|
|
|
async function readLoop() {
|
|
try {
|
|
while (true) {
|
|
if (!target.connected || target.killed) {
|
|
return;
|
|
}
|
|
const prom = op_node_ipc_read(ipc);
|
|
// there will always be a pending read promise,
|
|
// but it shouldn't keep the event loop from exiting
|
|
core.unrefOpPromise(prom);
|
|
const msg = await prom;
|
|
if (isInternal(msg)) {
|
|
const cmd = internalCmdName(msg);
|
|
if (cmd === "CLOSE") {
|
|
// Channel closed.
|
|
target.disconnect();
|
|
return;
|
|
} else {
|
|
// TODO(nathanwhit): once we add support for sending
|
|
// handles, if we want to support deno-node IPC interop,
|
|
// we'll need to handle the NODE_HANDLE_* messages here.
|
|
continue;
|
|
}
|
|
}
|
|
|
|
process.nextTick(handleMessage, msg);
|
|
}
|
|
} catch (err) {
|
|
if (
|
|
err instanceof Deno.errors.Interrupted ||
|
|
err instanceof Deno.errors.BadResource
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleMessage(msg) {
|
|
if (!target.channel) {
|
|
return;
|
|
}
|
|
if (target.listenerCount("message") !== 0) {
|
|
target.emit("message", msg);
|
|
return;
|
|
}
|
|
|
|
ArrayPrototypePush(target.channel[kPendingMessages], msg);
|
|
}
|
|
|
|
target.on("newListener", () => {
|
|
nextTick(() => {
|
|
if (!target.channel || !target.listenerCount("message")) {
|
|
return;
|
|
}
|
|
for (const msg of target.channel[kPendingMessages]) {
|
|
target.emit("message", msg);
|
|
}
|
|
target.channel[kPendingMessages] = [];
|
|
});
|
|
});
|
|
|
|
target.send = function (message, handle, options, callback) {
|
|
if (typeof handle === "function") {
|
|
callback = handle;
|
|
handle = undefined;
|
|
options = undefined;
|
|
} else if (typeof options === "function") {
|
|
callback = options;
|
|
options = undefined;
|
|
} else if (options !== undefined) {
|
|
validateObject(options, "options");
|
|
}
|
|
|
|
options = { swallowErrors: false, ...options };
|
|
|
|
if (message === undefined) {
|
|
throw new TypeError("ERR_MISSING_ARGS", "message");
|
|
}
|
|
|
|
if (handle !== undefined) {
|
|
notImplemented("ChildProcess.send with handle");
|
|
}
|
|
|
|
if (!target.connected) {
|
|
const err = new ERR_IPC_CHANNEL_CLOSED();
|
|
if (typeof callback === "function") {
|
|
process.nextTick(callback, err);
|
|
} else {
|
|
nextTick(() => target.emit("error", err));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// signals whether the queue is within the limit.
|
|
// if false, the sender should slow down.
|
|
// this acts as a backpressure mechanism.
|
|
const queueOk = [true];
|
|
control.refCounted();
|
|
op_node_ipc_write(ipc, message, queueOk)
|
|
.then(() => {
|
|
control.unrefCounted();
|
|
if (callback) {
|
|
process.nextTick(callback, null);
|
|
}
|
|
});
|
|
return queueOk[0];
|
|
};
|
|
|
|
target.connected = true;
|
|
|
|
target.disconnect = function () {
|
|
if (!target.connected) {
|
|
target.emit("error", new Error("IPC channel is already disconnected"));
|
|
return;
|
|
}
|
|
|
|
target.connected = false;
|
|
target[kCanDisconnect] = false;
|
|
control[kControlDisconnect]();
|
|
process.nextTick(() => {
|
|
target.channel = null;
|
|
core.close(ipc);
|
|
target.emit("disconnect");
|
|
});
|
|
};
|
|
target[kCanDisconnect] = true;
|
|
|
|
// Start reading messages from the channel.
|
|
readLoop();
|
|
|
|
return control;
|
|
}
|
|
|
|
export default {
|
|
ChildProcess,
|
|
normalizeSpawnArguments,
|
|
stdioStringToArray,
|
|
spawnSync,
|
|
setupChannel,
|
|
};
|