From b407154eee805a65739197652ea68391a9fecee2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 01:46:05 -0500 Subject: [PATCH 1/2] Do not merge: CI check of the exec lowering changes --- .../ambient/scriptc-node-fallback.d.ts | 8 +-- .../compiler/src/backend/llvm/lib-process.ts | 5 +- .../src/frontend/lowering/builtin-calls.ts | 4 +- .../lowering/builtins/child-process.ts | 72 +++++++++++++------ .../compiler/src/frontend/lowering/lowerer.ts | 4 +- .../compiler/src/ir/builtin-signatures.ts | 5 +- packages/compiler/src/ir/validate.test.ts | 1 + packages/runtime/src/scr_child.c | 8 +-- packages/runtime/src/scr_child.h | 6 +- 9 files changed, 73 insertions(+), 40 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index ff1fd81de3..49dbf948fb 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2971,10 +2971,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), diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 05d3c27848..8c3571c3d5 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -18,12 +18,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 }); } diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index 8aec7420fe..9c2eeed45c 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -55,8 +55,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); diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process.ts b/packages/compiler/src/frontend/lowering/builtins/child-process.ts index 8c06a28df6..ecec14fc5b 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process.ts @@ -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 , 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, + }; } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 25c70e2f4c..0fff3c86a7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11485,8 +11485,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 { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 179375bd30..0386d54a4b 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2222,8 +2222,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). diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 463b9bc626..56d571cfec 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -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([ diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 715b69ab0a..3bce01fc71 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -2265,14 +2265,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); @@ -4905,14 +4905,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); diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index 350995e315..1819b9c538 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -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); From 1e4a119d7476488edf751f7d1b6a07dc6a4964a6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 03:31:50 -0500 Subject: [PATCH 2/2] Run the native bootstrap lane --- tests/harness/self-hosting-native-driver.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 64227e7a8d..0caea765a1 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -1,3 +1,4 @@ +// CI-only: run the native bootstrap lane for this check. import { verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs"; import { execFile, spawnSync } from "node:child_process"; import {