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 e62e33894..b941b173f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11736,8 +11736,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.