Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions packages/compiler/src/frontend/lowering/lower-builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/compiler/src/frontend/lowering/surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/surface-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/test/ts7/baselines/order-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions tests/corpus/2912-execfile-callback-options.ts
Original file line number Diff line number Diff line change
@@ -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");
10 changes: 7 additions & 3 deletions tests/diagnostics/child-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]!);
Expand Down
62 changes: 40 additions & 22 deletions tests/harness/__snapshots__/child-process.ts.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Loading