Lower child_process.exec's callback forms (#1290)

This commit is contained in:
Chris Tate
2026-10-18 09:26:38 -05:00
committed by GitHub
26 changed files with 261 additions and 59 deletions
@@ -2,6 +2,6 @@
"schemaVersion": 3,
"nodeVersion": "24.22.0",
"nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93",
"artifactVersion": "cade1b8e6e0c5c42fcc9",
"artifactVersion": "be8d5b094695385e34ea",
"rowCount": 3880
}
@@ -164,9 +164,9 @@
"apiEntries": 39,
"static": {
"supported": 0,
"partial": 17,
"partial": 18,
"refused": 0,
"not-implemented": 22,
"not-implemented": 21,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -9312,9 +9312,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
}
},
{
@@ -7,8 +7,8 @@
"tierItemCount": 3456,
"tiers": {
"static": {
"audit-partial": 1143,
"verify-gap": 2186,
"audit-partial": 1144,
"verify-gap": 2185,
"replace-refusal": 40,
"classify": 2,
"implement": 85
@@ -3941,12 +3941,16 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:child_process",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:child_process.child_process.exec",
"tests": [
"tests/corpus/5627-exec-callback.ts",
"tests/fixtures/node-types/child-exec.ts",
"tests/harness/project-config.test.ts"
]
}
}
},
@@ -131,9 +131,9 @@
"apiEntries": 39,
"static": {
"supported": 0,
"partial": 17,
"partial": 18,
"refused": 0,
"not-implemented": 22,
"not-implemented": 21,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -11663,7 +11663,10 @@
"tests/corpus/3127-spawn-runtime-options.ts",
"tests/corpus/3130-child-generic-streams.ts",
"tests/corpus/3131-child-rest-listeners.js",
"tests/corpus/3132-child-generic-stream-completion.ts"
"tests/corpus/3132-child-generic-stream-completion.ts",
"tests/corpus/5627-exec-callback.ts",
"tests/fixtures/node-types/child-exec.ts",
"tests/harness/project-config.test.ts"
]
},
"anchorSource": "chapter"
@@ -11732,8 +11735,13 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:child_process"
"status": "partial",
"evidence": "compiler-feature:child_process.child_process.exec",
"tests": [
"tests/corpus/5627-exec-callback.ts",
"tests/fixtures/node-types/child-exec.ts",
"tests/harness/project-config.test.ts"
]
},
"anchorSource": "exact"
},
@@ -322,6 +322,7 @@
{ "chapter": "packages", "symbols": ["--input-type"], "status": "not-applicable" },
{ "chapter": "child_process", "signatures": ["`subprocess[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] },
{ "chapter": "child_process", "signatures": ["`child_process.execFile(file[, args][, options][, callback])`"], "status": "partial", "evidence": ["tests/corpus/2911-execfile-callback.ts"] },
{ "chapter": "child_process", "signatures": ["`child_process.exec(command[, options][, callback])`"], "status": "partial", "evidence": ["tests/corpus/5627-exec-callback.ts", "tests/fixtures/node-types/child-exec.ts", "tests/harness/project-config.test.ts"] },
{ "chapter": "child_process", "signatures": ["`child_process.fork(modulePath[, args][, options])`", "`subprocess.send(message[, sendHandle[, options]][, callback])`", "`subprocess.disconnect()`", "`connected` Type: {boolean} Set to `false` after `subprocess.disconnect()` is called.", "Event: `'message'`", "Event: `'disconnect'`"], "status": "partial", "evidence": ["tests/corpus/2946-child-fork-ipc/main.ts"] },
{ "chapter": "child_process", "signatures": ["Event: `'close'`"], "status": "partial", "evidence": ["tests/corpus/2939-child-shell-text.ts", "tests/corpus/3131-child-rest-listeners.js"] },
{ "chapter": "child_process", "signatures": ["`stdout` Type: {stream.Readable|null|undefined}", "`stderr` Type: {stream.Readable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/1565-spawn-pipe-streams.ts", "tests/corpus/2939-child-shell-text.ts"] },
+4 -4
View File
@@ -3033,10 +3033,10 @@ declare module "child_process" {
args?: string[] | null,
options?: ExecSyncOptions,
): unknown;
/* The async shell form — declared surface without full lowering (the
* type-level tolerance story): harness code on paths tests don't reach
* must typecheck; a reached call fences at its site. util.promisify
* recognizes it statically, as it does execFile. */
/* The async shell form. The callback forms lower like execFile's, with
* the command line run by /bin/sh -c, and util.promisify recognizes it
* statically, as it does execFile; reached calls without a callback
* fence at their site. */
export function exec(
command: string,
options?: ExecSyncOptions | ((error: Error | null, stdout: string, stderr: string) => void),
@@ -19,12 +19,13 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr
const argv = host.emitExpr(e.args[1]!);
const cb = host.emitExpr(callbackArg);
host.moveTemp(cb);
const shell = host.emitExpr(e.args[3]!);
const adapter = host.execFileThunkFor(cbT);
host.usesTimers = true;
host.declare(`declare ptr @scr_exec_file(ptr, ptr, ptr, ptr)`);
host.declare(`declare ptr @scr_exec_file(ptr, ptr, ptr, ptr, i1)`);
const out = B.tmp();
B.line(
`${out} = call ptr @scr_exec_file(ptr ${cmd.name}, ptr ${argv.name}, ptr ${cb.name}, ptr @${adapter})`,
`${out} = call ptr @scr_exec_file(ptr ${cmd.name}, ptr ${argv.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${shell.name})`,
);
return host.own({ name: out, type: e.type });
}
@@ -62,8 +62,8 @@ export function lowerBuiltinCall(
loc: SrcLoc,
namespaceMember?: ts.MemberName,
): IrExpr {
if (bi.module === "child_process" && bi.member === "execFile") {
return lowerer.lowerExecFileCall(expr, loc);
if (bi.module === "child_process" && (bi.member === "execFile" || bi.member === "exec")) {
return lowerer.lowerExecFileCall(expr, loc, bi.member === "exec");
}
if (bi.module === "events" && bi.member === "addAbortListener") {
return lowerAddAbortListener(lowerer, expr, loc);
@@ -725,35 +725,57 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr
}
/** `execFile(file[, args][, options], callback)` — the asynchronous callback
* slice. The child starts immediately with stdin/stdout/stderr piped; the
* runtime captures both outputs and invokes the error-first callback after
* settlement. The callback may ignore a suffix of `(error, stdout,
* slice. With `shell`, `exec(command[, options], callback)`: the command
* line runs with /bin/sh -c, as execSync runs it, and failure messages show
* the command line as Node's do. The child starts immediately with
* stdin/stdout/stderr piped; the runtime captures both outputs and invokes
* the error-first callback after settlement. The callback may ignore a suffix of `(error, stdout,
* stderr)`, but every declared parameter must have the Node shape. Inline
* `encoding: "utf8"` and a pure numeric `maxBuffer` are accepted; the latter
* is not enforced by the growing native capture, as
* with the synchronous and promisified capture forms. Other options remain
* fenced until their lifecycle can share this asynchronous core. */
export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr {
export function lowerExecFileCall(
lowerer: Lowerer,
expr: ts.CallExpression,
loc: SrcLoc,
shell = false,
): IrExpr {
const what = shell ? "exec" : "execFile";
if (
expr.arguments.some(ts.isSpreadElement) ||
expr.arguments.length < 2 ||
expr.arguments.length > 4
expr.arguments.length > (shell ? 3 : 4)
) {
lowerer.noLowering(
`execFile with ${expr.arguments.length} arguments`,
`${what} with ${expr.arguments.length} arguments`,
expr,
"the supported callback forms are execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback)",
shell
? "the supported callback forms are exec(command, callback) and exec(command, { encoding: 'utf8', maxBuffer: N }, callback)"
: "the supported callback forms are execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback)",
);
}
const cmd = lowerer.lowerExprExpecting(expr.arguments[0]!, STRING);
const argsNode = expr.arguments.length >= 3 ? expr.arguments[1] : undefined;
const first = lowerer.lowerExprExpecting(expr.arguments[0]!, STRING);
const callbackNode = expr.arguments[expr.arguments.length - 1]!;
const argv = lowerChildArgsArg(lowerer, argsNode, loc);
if (expr.arguments.length === 4) {
const options = expr.arguments[2]!;
// exec: /bin/sh -c <command>, execSync's lowering (the runtime reads the
// command line back from argv[1] for failure messages); execFile: the
// file and its args list.
const cmd: IrExpr = shell ? { kind: "strLit", value: "/bin/sh", type: STRING, loc } : first;
const argv: IrExpr = shell
? {
kind: "arrayLit",
elems: [{ kind: "strLit", value: "-c", type: STRING, loc }, first],
type: arrayOf(STRING),
loc,
}
: lowerChildArgsArg(lowerer, expr.arguments.length >= 3 ? expr.arguments[1] : undefined, loc);
const optionsNode =
expr.arguments.length === (shell ? 3 : 4) ? expr.arguments[shell ? 1 : 2] : undefined;
if (optionsNode) {
const options = optionsNode;
if (!ts.isObjectLiteralExpression(options)) {
lowerer.noLowering(
"execFile with a non-literal options argument",
`${what} with a non-literal options argument`,
options,
"pass encoding and maxBuffer in an inline object literal",
);
@@ -774,7 +796,7 @@ export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc
const member = optionMember(property);
if (!member)
lowerer.noLowering(
"execFile with this options shape",
`${what} with this options shape`,
property,
"use plain encoding and maxBuffer properties without spreads or computed keys",
);
@@ -784,7 +806,7 @@ export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc
(member.value.text !== "utf8" && member.value.text !== "utf-8")
) {
lowerer.noLowering(
"execFile with a non-literal utf8 encoding",
`${what} with a non-literal utf8 encoding`,
member.value,
"pass the literal 'utf8' or 'utf-8' (other expressions might have side effects)",
);
@@ -792,14 +814,14 @@ export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc
} else if (member.name === "maxBuffer") {
if (!pureNumber(member.value)) {
lowerer.noLowering(
"execFile with a non-literal maxBuffer",
`${what} with a non-literal maxBuffer`,
member.value,
"pass a numeric literal or literal arithmetic (the native capture grows without enforcing this limit)",
);
}
} else {
lowerer.noLowering(
`execFile option '${member.name}'`,
`${what} option '${member.name}'`,
property,
"only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form",
);
@@ -815,14 +837,14 @@ export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc
lowerer.unsupported(
"SC1090",
callbackNode,
"execFile callbacks take (error), (error, stdout), (error, stdout, stderr), or no parameters",
`${what} callbacks take (error), (error, stdout), (error, stdout, stderr), or no parameters`,
);
}
if (callback.type.ret.kind !== "void") {
lowerer.unsupported(
"SC1090",
callbackNode,
"execFile callbacks returning a value (make the callback body a block, or return nothing)",
`${what} callbacks returning a value (make the callback body a block, or return nothing)`,
);
}
const errorParam = callback.type.params[0];
@@ -837,7 +859,7 @@ export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc
lowerer.unsupported(
"SC1090",
callbackNode,
`execFile callbacks whose first parameter is not 'Error | null' (got '${lowerer.fmt(errorParam)}')`,
`${what} callbacks whose first parameter is not 'Error | null' (got '${lowerer.fmt(errorParam)}')`,
);
}
}
@@ -846,9 +868,15 @@ export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc
lowerer.unsupported(
"SC1090",
callbackNode,
`execFile callbacks whose ${i === 1 ? "stdout" : "stderr"} parameter is not 'string'`,
`${what} callbacks whose ${i === 1 ? "stdout" : "stderr"} parameter is not 'string'`,
);
}
}
return { kind: "libCall", fn: "cp.execFile", args: [cmd, argv, callback], type: CHILD_T, loc };
return {
kind: "libCall",
fn: "cp.execFile",
args: [cmd, argv, callback, boolLit(shell, loc)],
type: CHILD_T,
loc,
};
}
@@ -11,6 +11,28 @@ const ASSERTION_ERROR_PROPERTIES: ReadonlySet<string> = new Set([
"diff",
]);
/** True for child_process.ExecException, exec's callback error. Its
* `code` is the child's numeric exit status (null after a signal), which
* the runtime error's string code slot does not hold, so reads, `in` tests
* and typeof tests of `code` refuse instead of answering from the slot. */
export function isExecExceptionType(lowerer: Lowerer, t: ts.Type): boolean {
const sym = t.getSymbol();
return (
sym?.name === "ExecException" &&
lowerer.checker
.declarationsOf(sym)
.some((d) => ts.isInterfaceDeclaration(d) && lowerer.isStdlibFile(d.getSourceFile()))
);
}
export function refuseExecExceptionCode(lowerer: Lowerer, node: ts.Node): never {
return lowerer.noLowering(
"ExecException.code",
node,
"spawn reports the exit status through its 'close' event",
);
}
/** `.code` and `.cause` on an error-hierarchy receiver. The latter reads
* the hidden dyn slot installed by constructor options. The former is NodeJS.ErrnoException's
* member (the fallback declares the same shape): the runtime Error's
@@ -122,6 +144,9 @@ export function lowerErrorCodeProperty(
!(recvT.className === "%Error" && lowerer.typeOf(expr.expression).isIntersectionType())
)
return null;
if (expr.name.text === "code" && isExecExceptionType(lowerer, lowerer.typeOf(expr.expression))) {
refuseExecExceptionCode(lowerer, expr);
}
const rawReceiver = lowerer.lowerExpr(expr.expression);
const receiver =
rawReceiver.type.kind === "dyn"
@@ -13,6 +13,7 @@ import { lowerReadlineProperty } from "./builtins/readline.js";
import { lowerBlobInstanceof } from "./builtins/blob.js";
import { lowerKeyObjectInstanceof } from "./builtins/key-object.js";
import { lowerNetAddressInstanceof, lowerNetAddressStaticRead } from "./builtins/net-address.js";
import { isExecExceptionType, refuseExecExceptionCode } from "./builtins/errors.js";
import { STATS_TYPE_TESTS } from "./builtins/filesystem.js";
import { threadsImportOf } from "../threads-import.js";
import { experimentalImportOf, lifetimeRegionMemberOf } from "../experimental-import.js";
@@ -11326,6 +11327,7 @@ function lowerErrorCodeTypeofTest(
recv = recv.expression;
const errT = errorRootedObjectOf(lowerer, lowerer.typeOf(recv));
if (!errT) continue;
if (isExecExceptionType(lowerer, lowerer.typeOf(recv))) refuseExecExceptionCode(lowerer, prop);
const caught = lowerer.caughtLocalOf(recv);
let receiver: IrExpr;
if (caught) {
@@ -12928,6 +12930,9 @@ function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: Src
loc,
};
}
if (key === "code" && isExecExceptionType(lowerer, lowerer.typeOf(expr.right))) {
refuseExecExceptionCode(lowerer, expr);
}
let recv = lowerer.lowerExpr(expr.right);
const classInfo =
recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined;
@@ -11736,8 +11736,8 @@ export class Lowerer {
return lowerSpawnCall(this, expr, loc);
}
lowerExecFileCall(expr: ts.CallExpression, loc: SrcLoc): IrExpr {
return lowerExecFileCall(this, expr, loc);
lowerExecFileCall(expr: ts.CallExpression, loc: SrcLoc, shell = false): IrExpr {
return lowerExecFileCall(this, expr, loc, shell);
}
lowerExecSyncCall(expr: ts.CallExpression, shell: boolean, loc: SrcLoc): IrExpr {
@@ -1386,6 +1386,23 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
) {
return { kind: "object", className: "%Error" };
}
// child_process.ExecException, exec's callback error, is an interface
// extending Error with optional metadata members; the value is one
// ordinary Error as well.
const execException = type.getSymbol();
if (
execException?.name === "ExecException" &&
checker
.declarationsOf(execException)
.some(
(decl) =>
ts.isInterfaceDeclaration(decl) &&
ctx.isStdlibFile(decl.getSourceFile()) &&
isDeclaredInAmbientModule(decl, "child_process"),
)
) {
return { kind: "object", className: "%Error" };
}
if (isOsConstantsType(type, ctx) || isLegacyUrlType(type, ctx)) return DYN;
// Immutable<T> (@scriptc/types) is a deep-readonly view: the value is
// still the T it maps.
@@ -2343,8 +2343,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
/** child_process.execFile's callback slice: starts an all-piped child,
* captures stdout/stderr, and moves the error-first callback into the
* child registry. The callback shape is program-dependent and checked
* by the validator/backends. */
"cp.execFile": { argTypes: [STRING, arrayOf(STRING), null], result: CHILD_T },
* by the validator/backends. The last argument is exec's shell flag:
* the argv is ["-c", command] and failure messages show the command. */
"cp.execFile": { argTypes: [STRING, arrayOf(STRING), null, BOOL], result: CHILD_T },
// spawn's options form: per-slot stdio modes (0 ignore / 1 inherit /
// 2 fd) with the out/err fds for mode 2, detached, env replacement
// pairs, cwd ("" = inherit).
@@ -951,6 +951,7 @@ test("library callbacks retain child, specialized, and generic result diagnostic
{ kind: "strLit", value: "tool", type: STRING, loc },
{ kind: "arrayLit", elems: [], type: arrayOf(STRING), loc },
{ kind: "boolLit", value: true, type: F64, loc },
{ kind: "boolLit", value: false, type: BOOL, loc },
],
};
expect(validateModule(expressionModule(expr, [])).map((error) => error.message)).toEqual([
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5627-exec-callback.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/node-types/child-exec-code-fences.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/node-types/child-exec.ts"
],
"diags": []
}
+4 -4
View File
@@ -2266,14 +2266,14 @@ static void scr_exec_file_fire(ScrChild *c) {
}
ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb,
ScrExecFileFn fn) {
ScrExecFileFn fn, bool shell) {
ScrStr *empty = scr_str_new("", 0);
ScrChild *c = scr_spawn_opts(cmd, args, 3, 3, 3, 0, 0, false, false, false,
NULL, empty);
scr_str_release(empty);
c->exec_cb = cb;
c->exec_fn = fn;
char *display = scr_exec_display(cmd, args, false);
char *display = scr_exec_display(cmd, args, shell);
c->exec_display = scr_str_new(display, strlen(display));
free(display);
scr_child_stream_capture_begin(c->out_stream);
@@ -4911,14 +4911,14 @@ static void scr_exec_file_fire(ScrChild *c) {
}
ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb,
ScrExecFileFn fn) {
ScrExecFileFn fn, bool shell) {
ScrStr *empty = scr_str_new("", 0);
ScrChild *c = scr_spawn_opts(cmd, args, 3, 3, 3, 0, 0, false, false, false,
NULL, empty);
scr_str_release(empty);
c->exec_cb = cb;
c->exec_fn = fn;
char *display = scr_exec_display(cmd, args, false);
char *display = scr_exec_display(cmd, args, shell);
c->exec_display = scr_str_new(display, strlen(display));
free(display);
scr_child_stream_capture_begin(c->out_stream);
+4 -2
View File
@@ -155,9 +155,11 @@ ScrChild *scr_spawn_dynamic(ScrStr *cmd, ScrArr *args, const ScrDyn *options);
void scr_child_on_spawn(ScrChild *child, ScrClosure *cb); /* moves zero-argument callback */
/* The callback slice: file + args, default options. The returned child has
* all three stdio slots piped; stdout/stderr are captured internally and
* the callback moves into the child registry until settlement. */
* the callback moves into the child registry until settlement. With shell
* (exec), args is ["-c", command] for /bin/sh and failure messages show
* the command, as for scr_exec_sync. */
ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb /*moves*/,
ScrExecFileFn fn);
ScrExecFileFn fn, bool shell);
ScrChild *scr_child_retain(ScrChild *c);
void scr_child_release(ScrChild *c);
void *scr_child_retain_v(void *p);
+36
View File
@@ -0,0 +1,36 @@
// child_process.exec's callback forms: the command line runs with
// /bin/sh -c, the call returns a live ChildProcess immediately, utf8
// stdout/stderr are captured, and the error-first callback fires after the
// current turn. A non-zero exit (a missing command is the shell's 127)
// reports "Command failed: <command>\n<stderr>".
import { exec } from "node:child_process";
import * as childProcess from "node:child_process";
const child = exec("printf out; printf err >&2", (error, stdout, stderr) => {
console.log("ok", error === null, JSON.stringify(stdout), JSON.stringify(stderr));
exec("printf 'a\\nb\\nc\\n' | wc -l", (pipeError, lines) => {
console.log("pipe", pipeError === null, lines.trim());
exec(`printf '%s|' "a b" 'c d' e`, { encoding: "utf8", maxBuffer: 1024 * 1024 }, (_, quoted) => {
console.log("quoting", quoted);
exec("printf partial; printf failed >&2; exit 3", (failed, failedOut, failedErr) => {
console.log("fail", failed !== null, JSON.stringify(failedOut), JSON.stringify(failedErr));
console.log("fail-message", JSON.stringify(failed === null ? "" : failed.message));
exec("exit 1", (silent) => {
console.log("silent", JSON.stringify(silent === null ? "" : silent.message));
exec("scriptc-definitely-not-a-command", (missing, missingOut) => {
const message = missing === null ? "" : missing.message;
console.log("missing", missing !== null, JSON.stringify(missingOut));
console.log("missing-message", message.split("\n")[0]);
exec("true", () => {
console.log("zero-parameter callback");
childProcess.exec("printf namespace", (_, out) => console.log(out));
});
});
});
});
});
});
});
console.log("returned", child.pid !== undefined, child.stdout !== null, child.stderr !== null);
console.log("scheduled");
+8
View File
@@ -0,0 +1,8 @@
import { exec } from "node:child_process";
exec("exit 3", (error) => {
if (error === null) return;
console.log(error.code);
console.log("code" in error);
console.log(typeof error.code === "number");
});
+9
View File
@@ -0,0 +1,9 @@
import { exec } from "node:child_process";
exec("printf typed-exec; printf err >&2", (error, stdout, stderr) => {
if (error !== null) throw error;
console.log(stdout, stderr);
exec("exit 2", { encoding: "utf8" }, (failed) => {
console.log(JSON.stringify(failed === null ? "" : failed.message));
});
});
+2
View File
@@ -1834,6 +1834,7 @@
"5566-fs-dirent-dyn-boundaries/main.mjs": 2607,
"5585-record-results-inlined.ts": 2922,
"5595-net-ref-unref.ts": 2593,
"5627-exec-callback.ts": 1900,
"5644-net-socket-addresses.ts": 2220,
"5645-net-socket-callbacks.ts": 2442,
"5646-net-server-connections.ts": 2389,
@@ -4534,6 +4535,7 @@
"5566-fs-dirent-dyn-boundaries/main.mjs": 3333,
"5585-record-results-inlined.ts": 3681,
"5595-net-ref-unref.ts": 3036,
"5627-exec-callback.ts": 2500,
"5644-net-socket-addresses.ts": 2393,
"5645-net-socket-callbacks.ts": 2060,
"5646-net-server-connections.ts": 2755,
+34
View File
@@ -462,6 +462,40 @@ test("node-types: unsupported child stdin forms remain named fences", async () =
]);
});
test("node-types: callback exec uses the typed ExecException overloads", async () => {
const dir = outDirFor("node-child-exec");
const entry = join(nodeTypesDir, "child-exec.ts");
const result = await compile(entry, {
outPath: join(dir, "child-exec"),
outDir: dir,
sanitize,
});
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true);
if (!result.ok) return;
const { stdout } = await execFileAsync(result.binaryPath);
expect(stdout).toBe('typed-exec err\n"Command failed: exit 2\\n"\n');
});
test("node-types: exec's numeric ExecException.code remains a named fence", async () => {
const dir = outDirFor("node-child-exec-code-fences");
const entry = join(nodeTypesDir, "child-exec-code-fences.ts");
const result = await compile(entry, {
outPath: join(dir, "child-exec-code-fences"),
outDir: dir,
sanitize,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
"SC2020",
"SC2020",
"SC2020",
]);
for (const diagnostic of result.diagnostics) {
expect(diagnostic.message).toContain("ExecException.code");
}
});
test("node-types: path and os lower statically under @types/node's shapes", async () => {
const outDir = outDirFor("node-path-os");
const result = await compile(join(nodeTypesDir, "path-os.ts"), {
@@ -149,6 +149,8 @@ const WINDOWS_SKIPS: Record<string, string> = {
"posix-shaped: the uncaught sh ENOENT throw crashes both sides, rendered differently (1645 covers env here)",
"1464-env-writes.ts":
'posix-shaped: the observing child is ENOENT on Windows, exposing the documented spawn-failure ""-vs-null stance',
"5627-exec-callback.ts":
"posix-shaped: exec runs /bin/sh -c (execSync's static shell lowering); Windows Node runs cmd.exe",
// Two programs the events unit's win32 arm surfaced (they compiled for
// the first time once the events gate lifted): both drive their signal
// listeners through a /bin/sh child, ENOENT on Windows Node too.