feat(child_process): support inline execFile callback options (#387)

* feat(child_process): lower UTF-8 execFile callback options

* test(ts7): record execFile callback options fixture order
This commit is contained in:
Chris Tate
2026-09-23 00:13:44 -05:00
committed by GitHub
parent 9ff5ed1680
commit e334ae444e
7 changed files with 115 additions and 35 deletions
@@ -3168,25 +3168,54 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr
};
}
/** `execFile(file[, args], callback)` — the first asynchronous callback
/** `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,
* stderr)`, but every declared parameter must have the Node shape. Options
* remain fenced until their timeout/env/cwd lifecycle can share this same
* asynchronous core without falling back to the old blocking capture. */
* 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 {
if (expr.arguments.some(ts.isSpreadElement) || expr.arguments.length < 2 || expr.arguments.length > 3) {
if (expr.arguments.some(ts.isSpreadElement) || expr.arguments.length < 2 || expr.arguments.length > 4) {
lowerer.noLowering(
`execFile with ${expr.arguments.length} arguments`,
expr,
"the supported callback forms are execFile(file, callback) and execFile(file, args, 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 argsNode = expr.arguments.length >= 3 ? expr.arguments[1] : undefined;
const callbackNode = expr.arguments[expr.arguments.length - 1]!;
const argv = lowerer.lowerChildArgsArg(argsNode, loc);
if (expr.arguments.length === 4) {
const options = expr.arguments[2]!;
if (!ts.isObjectLiteralExpression(options)) {
lowerer.noLowering("execFile with a non-literal options argument", options, "pass encoding and maxBuffer in an inline object literal");
}
const pureNumber = (node: ts.Expression): boolean =>
ts.isNumericLiteral(node) ||
(ts.isParenthesizedExpression(node) && pureNumber(node.expression)) ||
(ts.isBinaryExpression(node) &&
[ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken, ts.SyntaxKind.AsteriskToken, ts.SyntaxKind.SlashToken].includes(node.operatorToken.kind) &&
pureNumber(node.left) && pureNumber(node.right));
for (const property of options.properties) {
const member = optionMember(property);
if (!member) lowerer.noLowering("execFile with this options shape", property, "use plain encoding and maxBuffer properties without spreads or computed keys");
if (member.name === "encoding") {
if (!ts.isStringLiteral(member.value) || (member.value.text !== "utf8" && member.value.text !== "utf-8")) {
lowerer.noLowering("execFile with a non-literal utf8 encoding", member.value, "pass the literal 'utf8' or 'utf-8' (other expressions might have side effects)");
}
} else if (member.name === "maxBuffer") {
if (!pureNumber(member.value)) {
lowerer.noLowering("execFile 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}'`, property, "only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form");
}
}
}
const callback = lowerer.lowerExpr(callbackNode);
if (callback.type.kind !== "func" || callback.type.rest === true || callback.type.params.length > 3) {
lowerer.unsupported(
@@ -1386,8 +1386,8 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record<string, Record<string, string |
},
child_process: {
execFile:
"the callback forms execFile(file, callback) and execFile(file, args, callback) lower; " +
"options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice",
"the callback forms execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback) lower; " +
"maxBuffer is not enforced by the growing native capture; other options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice",
},
crypto: {
...Object.fromEntries(
+1 -1
View File
@@ -445,7 +445,7 @@
"name": "child_process.execFile",
"status": "unsupported",
"code": "SC2020",
"note": "the callback forms execFile(file, callback) and execFile(file, args, callback) lower; options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice"
"note": "the callback forms execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback) lower; maxBuffer is not enforced by the growing native capture; other options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice"
},
{
"id": "node-builtin.child_process.execFileSync",
@@ -6313,6 +6313,12 @@
],
"diags": []
},
"<repo>/tests/corpus/2912-execfile-callback-options.ts": {
"order": [
"<repo>/tests/corpus/2912-execfile-callback-options.ts"
],
"diags": []
},
"<repo>/tests/corpus/2912-promisify-fs-readfile.ts": {
"order": [
"<repo>/tests/corpus/2912-promisify-fs-readfile.ts"
@@ -0,0 +1,23 @@
// The inline utf8/maxBuffer callback shape preserves concurrent execution,
// streams and error-first result on both successful and failed children.
import { execFile } from "node:child_process";
const child = execFile(
"/bin/sh",
["-c", "printf out; printf note >&2"],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
(error: Error | null, stdout, stderr) => {
console.log("ok", error === null, JSON.stringify(stdout), JSON.stringify(stderr));
execFile(
"/bin/sh",
["-c", "printf partial; printf failed >&2; exit 3"],
{ encoding: "utf8", maxBuffer: 67108864 },
(failed: Error | null, failedOut, failedErr) => {
console.log("failed", failed !== null, JSON.stringify(failedOut), JSON.stringify(failedErr));
console.log("error", failed === null ? "" : failed.message.includes("exit 3"));
},
);
},
);
console.log("returned", child.pid !== undefined);
console.log("scheduled");
+7 -3
View File
@@ -27,10 +27,14 @@ const c = spawn("true", [], { stdio: "ignore" });
c.on("exit", (): number => 5);
// Methods have no bound-value form — call on directly.
const f = c.on;
// The callback slice is deliberately narrower than Node's complete
// options/optional-callback overload family.
// The callback slice accepts inline utf8/maxBuffer options, but not all of
// Node's options or the optional-callback overload family.
execFile("true");
execFile("true", [], { encoding: "utf8" }, () => {});
execFile("true", [], { cwd: "/tmp" }, () => {});
const bufferLimit = 1024;
execFile("true", [], { maxBuffer: bufferLimit }, () => {});
const getEncoding = (): "utf8" => "utf8";
execFile("true", [], { encoding: getEncoding() }, () => {});
// Fork targets are part of the compiled graph and therefore must resolve at
// build time. The remaining channel is JSON-only and occupies stdio slot 3.
fork(process.argv[1]!);
@@ -28,7 +28,7 @@ child-process.ts:29:7 - error SC2007: values of type '{ (event: "exit", listener
28 | // Methods have no bound-value form — call on directly.
29 | const f = c.on;
| ^
30 | // The callback slice is deliberately narrower than Node's complete
30 | // The callback slice accepts inline utf8/maxBuffer options, but not all of
hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow
@@ -37,49 +37,67 @@ child-process.ts:29:11 - error SC1090: child methods as values (call 'on' direct
28 | // Methods have no bound-value form — call on directly.
29 | const f = c.on;
| ^~~~
30 | // The callback slice is deliberately narrower than Node's complete
30 | // The callback slice accepts inline utf8/maxBuffer options, but not all of
child-process.ts:32:1 - error SC2020: 'execFile with 1 arguments' is part of the standard library types but has no scriptc lowering yet
31 | // options/optional-callback overload family.
31 | // Node's options or the optional-callback overload family.
32 | execFile("true");
| ^~~~~~~~~~~~~~~~
33 | execFile("true", [], { encoding: "utf8" }, () => {});
33 | execFile("true", [], { cwd: "/tmp" }, () => {});
hint: the supported callback forms are execFile(file, callback) and execFile(file, args, callback)
hint: the supported callback forms are execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback)
child-process.ts:33:1 - error SC2020: 'execFile with 4 arguments' is part of the standard library types but has no scriptc lowering yet
child-process.ts:33:24 - error SC2020: 'execFile option 'cwd'' is part of the standard library types but has no scriptc lowering yet
32 | execFile("true");
33 | execFile("true", [], { encoding: "utf8" }, () => {});
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
34 | // Fork targets are part of the compiled graph and therefore must resolve at
33 | execFile("true", [], { cwd: "/tmp" }, () => {});
| ^~~~~~~~~~~
34 | const bufferLimit = 1024;
hint: the supported callback forms are execFile(file, callback) and execFile(file, args, callback)
hint: only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form
child-process.ts:36:6 - error SC2020: 'fork with a runtime-valued module path' is part of the standard library types but has no scriptc lowering yet
child-process.ts:35:35 - error SC2020: 'execFile with a non-literal maxBuffer' is part of the standard library types but has no scriptc lowering yet
35 | // build time. The remaining channel is JSON-only and occupies stdio slot 3.
36 | fork(process.argv[1]!);
34 | const bufferLimit = 1024;
35 | execFile("true", [], { maxBuffer: bufferLimit }, () => {});
| ^~~~~~~~~~~
36 | const getEncoding = (): "utf8" => "utf8";
hint: pass a numeric literal or literal arithmetic (the native capture grows without enforcing this limit)
child-process.ts:37:34 - error SC2020: 'execFile with a non-literal utf8 encoding' is part of the standard library types but has no scriptc lowering yet
36 | const getEncoding = (): "utf8" => "utf8";
37 | execFile("true", [], { encoding: getEncoding() }, () => {});
| ^~~~~~~~~~~~~
38 | // Fork targets are part of the compiled graph and therefore must resolve at
hint: pass the literal 'utf8' or 'utf-8' (other expressions might have side effects)
child-process.ts:40:6 - error SC2020: 'fork with a runtime-valued module path' is part of the standard library types but has no scriptc lowering yet
39 | // build time. The remaining channel is JSON-only and occupies stdio slot 3.
40 | fork(process.argv[1]!);
| ^~~~~~~~~~~~~~~~
37 | const staticWorker = new URL("./child-process.ts", import.meta.url);
41 | const staticWorker = new URL("./child-process.ts", import.meta.url);
hint: resolve a relative worker with new URL("./worker.ts", import.meta.url) or fileURLToPath(...) and bind it to a const if needed
child-process.ts:38:41 - error SC2020: 'fork with non-JSON serialization' is part of the standard library types but has no scriptc lowering yet
child-process.ts:42:41 - error SC2020: 'fork with non-JSON serialization' is part of the standard library types but has no scriptc lowering yet
37 | const staticWorker = new URL("./child-process.ts", import.meta.url);
38 | fork(staticWorker, [], { serialization: "advanced" });
41 | const staticWorker = new URL("./child-process.ts", import.meta.url);
42 | fork(staticWorker, [], { serialization: "advanced" });
| ^~~~~~~~~~
39 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] });
43 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] });
hint: the static IPC channel supports Node's default serialization or serialization: "json"
child-process.ts:39:64 - error SC2020: 'fork without a fourth-slot IPC channel' is part of the standard library types but has no scriptc lowering yet
child-process.ts:43:64 - error SC2020: 'fork without a fourth-slot IPC channel' is part of the standard library types but has no scriptc lowering yet
38 | fork(staticWorker, [], { serialization: "advanced" });
39 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] });
42 | fork(staticWorker, [], { serialization: "advanced" });
43 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] });
| ^~~~~~~~
40 | // Keep each fence on its own statement so diagnostics remain site-specific.
44 | // Keep each fence on its own statement so diagnostics remain site-specific.
hint: the fourth stdio entry must be "ipc"