From a478ced73f4b21a8a30b22a6d7ee3ad39e366f0f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 22 Sep 2026 23:42:45 -0500 Subject: [PATCH 1/2] feat(child_process): lower UTF-8 execFile callback options --- .../src/frontend/lowering/lower-builtins.ts | 43 ++++++++++--- .../src/frontend/lowering/surfaces.ts | 4 +- packages/compiler/surface-manifest.json | 2 +- .../corpus/2912-execfile-callback-options.ts | 23 +++++++ tests/diagnostics/child-process.ts | 10 ++- .../__snapshots__/child-process.ts.txt | 62 ++++++++++++------- 6 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 tests/corpus/2912-execfile-callback-options.ts diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 4e2c4235b..7f63b8998 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -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( diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index c15881564..f530e084a 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1386,8 +1386,8 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record&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"); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index bb8834111..f96bce418 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -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]!); diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index a828d3476..b43b96bf1 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -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" \ No newline at end of file From b7c2582b0a4f3451667f1728695577d1e0ef532b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 23 Sep 2026 00:02:00 -0500 Subject: [PATCH 2/2] test(ts7): record execFile callback options fixture order --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index e7185ea37..29ec06190 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6313,6 +6313,12 @@ ], "diags": [] }, + "/tests/corpus/2912-execfile-callback-options.ts": { + "order": [ + "/tests/corpus/2912-execfile-callback-options.ts" + ], + "diags": [] + }, "/tests/corpus/2912-promisify-fs-readfile.ts": { "order": [ "/tests/corpus/2912-promisify-fs-readfile.ts"