From 9d0012ddabdc269f4729927e6817a5108946470a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 09:05:09 -0500 Subject: [PATCH] Lower child_process.exec's callback forms exec(command, callback) and exec(command, { encoding, maxBuffer }, callback) lower like execFile's callback slice. The command line runs with /bin/sh -c, as execSync runs it, the call returns the live ChildProcess, and the error-first callback receives the captured utf8 stdout and stderr. A non-zero exit, including the shell's 127 for a missing command, reports Node's "Command failed: \n": scr_exec_file takes a shell flag so the message shows the command line. Under @types/node, child_process.ExecException maps to Error, as ExecFileException does. Its code member is the child's numeric exit status, which the runtime error's string code slot does not hold, so reading it, testing it with `in`, or testing its typeof is a named refusal. --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 +-- .../generated/node-v24-backlog.json | 20 +++--- .../generated/node-v24-internal.json | 18 +++-- internal/compatibility/static-support.json | 1 + .../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 +++++++++++++------ .../src/frontend/lowering/builtins/errors.ts | 25 +++++++ .../src/frontend/lowering/lower-exprs.ts | 5 ++ .../compiler/src/frontend/lowering/lowerer.ts | 4 +- packages/compiler/src/frontend/type-mapper.ts | 17 +++++ .../compiler/src/ir/builtin-signatures.ts | 5 +- packages/compiler/src/ir/validate.test.ts | 1 + .../tests/corpus/5627-exec-callback.ts.json | 6 ++ .../node-types/child-exec-code-fences.ts.json | 6 ++ .../fixtures/node-types/child-exec.ts.json | 6 ++ packages/runtime/src/scr_child.c | 8 +-- packages/runtime/src/scr_child.h | 6 +- tests/corpus/5627-exec-callback.ts | 36 ++++++++++ .../node-types/child-exec-code-fences.ts | 8 +++ tests/fixtures/node-types/child-exec.ts | 9 +++ tests/harness/corpus-costs.json | 2 + tests/harness/project-config.test.ts | 34 +++++++++ tests/harness/windows-differential.test.ts | 2 + 26 files changed, 261 insertions(+), 59 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5627-exec-callback.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec-code-fences.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec.ts.json create mode 100644 tests/corpus/5627-exec-callback.ts create mode 100644 tests/fixtures/node-types/child-exec-code-fences.ts create mode 100644 tests/fixtures/node-types/child-exec.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index db36cdb52..f2bb89f6e 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "cade1b8e6e0c5c42fcc9", + "artifactVersion": "be8d5b094695385e34ea", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index dadc9e836..3c0350dc3 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -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" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 1b7d28783..24821377e 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -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" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 8463d1292..3587ae2cb 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -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" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index e3aec2645..fded9de97 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -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"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 8cb8c972f..69093a600 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -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), diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index b30c65b8d..546c9a209 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -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 }); } diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index 037b3e5a7..7bb5f3e62 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -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); diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process.ts b/packages/compiler/src/frontend/lowering/builtins/child-process.ts index 8c06a28df..ecec14fc5 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/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 5947f3714..50a97cead 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -11,6 +11,28 @@ const ASSERTION_ERROR_PROPERTIES: ReadonlySet = 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" diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d2d990e16..0fb81e2da 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 087b4e628..adb16233d 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11720,8 +11720,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/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 52964e1bd..8157d8c04 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -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 (@scriptc/types) is a deep-readonly view: the value is // still the T it maps. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 7f31c5205..ca2749591 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -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). diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index d44a187f4..b7caf8c41 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/compiler/test/ts7/baselines/order-parity/tests/corpus/5627-exec-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5627-exec-callback.ts.json new file mode 100644 index 000000000..cbefe8acc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5627-exec-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5627-exec-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec-code-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec-code-fences.ts.json new file mode 100644 index 000000000..8f8a2a4b8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec-code-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/child-exec-code-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec.ts.json new file mode 100644 index 000000000..73db22aca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/child-exec.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 2d72c21d2..1235001ba 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -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); diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index 350995e31..1819b9c53 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); diff --git a/tests/corpus/5627-exec-callback.ts b/tests/corpus/5627-exec-callback.ts new file mode 100644 index 000000000..eed79e738 --- /dev/null +++ b/tests/corpus/5627-exec-callback.ts @@ -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: \n". +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"); diff --git a/tests/fixtures/node-types/child-exec-code-fences.ts b/tests/fixtures/node-types/child-exec-code-fences.ts new file mode 100644 index 000000000..71f627df4 --- /dev/null +++ b/tests/fixtures/node-types/child-exec-code-fences.ts @@ -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"); +}); diff --git a/tests/fixtures/node-types/child-exec.ts b/tests/fixtures/node-types/child-exec.ts new file mode 100644 index 000000000..97e9c546d --- /dev/null +++ b/tests/fixtures/node-types/child-exec.ts @@ -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)); + }); +}); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 2c80c39a2..9e0ad8cc4 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -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, diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index bbd6045e5..f5e80beb8 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -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"), { diff --git a/tests/harness/windows-differential.test.ts b/tests/harness/windows-differential.test.ts index 7e6ed205d..dc199d38f 100644 --- a/tests/harness/windows-differential.test.ts +++ b/tests/harness/windows-differential.test.ts @@ -149,6 +149,8 @@ const WINDOWS_SKIPS: Record = { "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.