diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index e5fd082f61..f56b73f360 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": 18, + "partial": 19, "refused": 0, - "not-implemented": 21, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -9519,9 +9519,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 51459d3b3c..dbdcc49883 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3454, "tiers": { "static": { - "audit-partial": 1197, - "verify-gap": 2130, + "audit-partial": 1198, + "verify-gap": 2129, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -4221,12 +4221,15 @@ "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.maxBuffer", + "tests": [ + "tests/corpus/5637-child-max-buffer-sync.ts", + "tests/corpus/5638-child-max-buffer-async.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 0e1e73a268..241d09552c 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": 18, + "partial": 19, "refused": 0, - "not-implemented": 21, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -11674,6 +11674,8 @@ "tests/corpus/3131-child-rest-listeners.js", "tests/corpus/3132-child-generic-stream-completion.ts", "tests/corpus/5627-exec-callback.ts", + "tests/corpus/5637-child-max-buffer-sync.ts", + "tests/corpus/5638-child-max-buffer-async.ts", "tests/fixtures/node-types/child-exec.ts", "tests/harness/project-config.test.ts" ] @@ -12029,8 +12031,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:child_process" + "status": "partial", + "evidence": "compiler-feature:child_process.maxBuffer", + "tests": [ + "tests/corpus/5637-child-max-buffer-sync.ts", + "tests/corpus/5638-child-max-buffer-async.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 734e038efb..6ac212fb33 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -343,6 +343,7 @@ { "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": ["`maxBuffer` and Unicode"], "status": "partial", "evidence": ["tests/corpus/5637-child-max-buffer-sync.ts", "tests/corpus/5638-child-max-buffer-async.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/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 4a7be1a35f..0126425160 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -20,14 +20,17 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr const cb = host.emitExpr(callbackArg); host.toHeap(cb); const shell = host.emitExpr(e.args[3]!); + const maxBuffer = host.emitExpr(e.args[4]!); const adapter = host.execFileThunkFor(cbT); host.usesTimers = true; - host.declare(`declare ptr @scr_exec_file(ptr, ptr, ptr, ptr, i1)`); + host.declare(`declare ptr @scr_exec_file(ptr, ptr, ptr, ptr, i1, double)`); const out = B.tmp(); B.line( - `${out} = call ptr @scr_exec_file(ptr ${cmd.name}, ptr ${argv.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${shell.name})`, + `${out} = call ptr @scr_exec_file(ptr ${cmd.name}, ptr ${argv.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${shell.name}, double ${maxBuffer.name})`, ); - return host.own({ name: out, type: e.type }); + const owned = host.own({ name: out, type: e.type }); + host.emitPendingCheck(); + return owned; } if ( e.fn === "cp.spawn" || diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 91242b45c8..cfd001f6ed 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -724,7 +724,8 @@ export const LIB_FN_SYMS: Record = { "process.offSignal": "scr_signal_off", "process.offExit": "scr_process_off_exit", // child_process (scr_child.c): spawnSync/exec forms are synchronous - // (execSync/execCapture throw — the may-throw check); handle getters + // (spawnSync, execSync, execCapture and execValidate throw — the + // may-throw check); handle getters // are pure reads. Registration forms are special-cased (moves + per- // shape adapters); spawn itself is special-cased for usesTimers. "cp.spawnSync": "scr_spawn_sync", @@ -732,6 +733,7 @@ export const LIB_FN_SYMS: Record = { "cp.spawnSyncStdioStr": "scr_spawn_sync_stdio_str", "cp.execSync": "scr_exec_sync", "cp.execCapture": "scr_exec_capture", + "cp.execValidate": "scr_exec_validate", "spawnRes.stdout": "scr_spawn_res_stdout", "spawnRes.stderr": "scr_spawn_res_stderr", "child.killed": "scr_child_killed", diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-async.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-async.ts index 5590fbe9fa..5d7b298780 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-async.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-async.ts @@ -13,6 +13,7 @@ import { SPAWNRES_T, STRING, type SrcLoc, + VOID, arrayOf, } from "../../../ir/ir.js"; import { optionMember } from "./arguments.js"; @@ -28,7 +29,8 @@ import { execResultShape } from "../../type-mapper.js"; * promisified execFile/exec behind an already-settled promise (the * fs/promises stance, divergence 23). Options are the exec-sync slice minus stdio (the async form always * captures both streams, no echo): encoding must spell utf8, cwd/env/ - * timeout lower, maxBuffer/windowsHide are accepted no-ops, killSignal + * timeout lower, maxBuffer applies per stream (Node's RangeError + * ERR_CHILD_PROCESS_STDIO_MAXBUFFER), windowsHide is a no-op, killSignal * only as its SIGTERM default. Rejections carry Node's async messages * ("Command failed: \n" — the trailing newline is Node's — * and "spawn ENOENT" with .code); Node's numeric `.code` on a @@ -85,6 +87,8 @@ export function lowerExecFileAsyncCall( let cwd: IrExpr = emptyStr; let env: IrExpr = envRec(false, emptyPairs()); let timeout: IrExpr = numLit(0, loc); + // Node's default maxBuffer for exec and execFile. + let maxBuffer: IrExpr = numLit(1024 * 1024, loc); if (optsNode) { if (!ts.isObjectLiteralExpression(optsNode)) { lowerer.noLowering( @@ -161,7 +165,7 @@ export function lowerExecFileAsyncCall( timeout = lowerer.lowerExprExpecting(m.value, F64); break; case "maxBuffer": - lowerer.lowerExpr(m.value); // accepted, not enforced (divergence 50) + maxBuffer = lowerer.lowerExprExpecting(m.value, F64); break; case "windowsHide": lowerer.lowerExpr(m.value); // Node no-op on POSIX @@ -186,18 +190,51 @@ export function lowerExecFileAsyncCall( } } } + const resultT: IrType = { kind: "promise", inner: { kind: "record", shapeId: helper.shapeId } }; + const args = [cmd, argv, cwd, env, timeout, maxBuffer]; + if (literalOptionsValid(timeout, maxBuffer)) { + return { kind: "call", callee: helper.name, args, type: resultT, loc }; + } + // Node's promisified forms call execFile synchronously, so an invalid + // timeout or maxBuffer throws before the promise exists: the arguments + // are evaluated in order, checked, then passed to the helper. + const types = [STRING, arrayOf(STRING), STRING, envRecT, F64, F64]; + const stmts: IrStmt[] = []; + const saved = args.map((init, i) => { + const local = lowerer.declareHiddenLocal("%execArg", types[i]!); + stmts.push({ kind: "varDecl", localId: local.id, init, loc }); + return varRef(local.id, types[i]!, loc); + }); + stmts.push({ + kind: "exprStmt", + expr: { kind: "libCall", fn: "cp.execValidate", args: [saved[4]!, saved[5]!], type: VOID, loc }, + loc, + }); return { - kind: "call", - callee: helper.name, - args: [cmd, argv, cwd, env, timeout], - type: { kind: "promise", inner: { kind: "record", shapeId: helper.shapeId } }, + kind: "seqExpr", + stmts, + result: { kind: "call", callee: helper.name, args: saved, type: resultT, loc }, + type: resultT, loc, }; } +/** Literal options Node's validateTimeout and validateMaxBuffer accept, + * which need no check before the call. */ +function literalOptionsValid(timeout: IrExpr, maxBuffer: IrExpr): boolean { + return ( + timeout.kind === "numLit" && + Number.isSafeInteger(timeout.value) && + timeout.value >= 0 && + maxBuffer.kind === "numLit" && + maxBuffer.value >= 0 + ); +} + /** The interned `%execFileAsync` helper behind promisified-execFile * calls (`%execAsync`, with `shell`, behind promisified-exec ones): an - * ASYNC IR function (params: cmd, argv, cwd, env {has, pairs}, timeoutMs) + * ASYNC IR function (params: cmd, argv, cwd, env {has, pairs}, timeoutMs, + * maxBuffer) * whose body runs the may-throw cp.execCapture libCall — the async * machinery turns a throw into the rejection, Node's promisified * behavior — and returns the `{ stdout, stderr }` record built from the @@ -253,6 +290,7 @@ function execFileAsyncHelper( loc, }, varRef("timeout.0", F64, loc), + varRef("maxBuffer.0", F64, loc), ], type: SPAWNRES_T, loc, @@ -299,6 +337,7 @@ function execFileAsyncHelper( { localId: "cwd.0", name: "cwd", type: STRING }, { localId: "env.0", name: "env", type: envRecT }, { localId: "timeout.0", name: "timeout", type: F64 }, + { localId: "maxBuffer.0", name: "maxBuffer", type: F64 }, ], returnType: recT, async: true, @@ -308,6 +347,7 @@ function execFileAsyncHelper( { id: "cwd.0", name: "cwd", type: STRING, mutable: false }, { id: "env.0", name: "env", type: envRecT, mutable: false }, { id: "timeout.0", name: "timeout", type: F64, mutable: false }, + { id: "maxBuffer.0", name: "maxBuffer", type: F64, mutable: false }, { id: "r.0", name: "r", type: SPAWNRES_T, mutable: false }, ], body, diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts index 24adcc1ba1..ad95ab528d 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts @@ -26,8 +26,8 @@ import { optionMember } from "./arguments.js"; * are drawn from the honestly-implemented set — `encoding` (must be the * "utf8"/"utf-8" literal, like spawnSync — outputs are captured utf8), * `cwd`, `env`, `input`, `timeout`, `stdio` (the "pipe"/"ignore" - * string form or a 3-tuple of those), `maxBuffer` (accepted, not - * enforced — the capture grows), `killSignal` (accepted only as the + * string form or a 3-tuple of those), `maxBuffer` (Node's 1 MiB default + * otherwise; 0 means no limit), `killSignal` (accepted only as the * SIGTERM default), `windowsHide`/`shell:false` on execFileSync (Node * no-ops here). Every other member (a non-default killSignal, shell:true * on execFileSync, ...) fences by name. */ @@ -99,6 +99,8 @@ export function lowerExecSyncCall( let hasEnv: IrExpr = boolLit(false, loc); let envPairs: IrExpr = emptyPairs; let timeout: IrExpr = numLit(0, loc); + // Node's default maxBuffer for the sync forms. + let maxBuffer: IrExpr = numLit(1024 * 1024, loc); let stdoutMode = 1; let stderrMode = 0; let stdinInherit = false; @@ -138,6 +140,7 @@ export function lowerExecSyncCall( runtime.stderrMode, // The runtime options carry a utf8 encoding (others throw). boolLit(false, loc), + runtime.maxBuffer, ], type: STRING, loc, @@ -338,9 +341,7 @@ export function lowerExecSyncCall( applyStdio(m.value); break; case "maxBuffer": - // Accepted, not enforced (the capture grows unbounded — no - // real corpus hits the cap); evaluate for side effects. - lowerer.lowerExpr(m.value); + maxBuffer = lowerer.lowerExprExpecting(m.value, F64); break; case "killSignal": { const t = lowerer.typeOf(m.value); @@ -401,6 +402,7 @@ export function lowerExecSyncCall( numLit(stdoutMode + (stdinInherit ? 4 : 0), loc), numLit(stderrMode, loc), boolLit(!encodingGiven, loc), + maxBuffer, ], type: STRING, loc, @@ -445,6 +447,7 @@ function lowerExecSyncRuntimeOptions( hasInput: IrExpr; cwd: IrExpr; timeout: IrExpr; + maxBuffer: IrExpr; stdoutMode: IrExpr; stderrMode: IrExpr; } | null { @@ -518,6 +521,7 @@ function lowerExecSyncRuntimeOptions( hasInput, cwd: readOpt("cwd", emptyStr, STRING), timeout: readOpt("timeout", { kind: "numLit", value: 0, type: F64, loc }, F64), + maxBuffer: readOpt("maxBuffer", { kind: "numLit", value: 1024 * 1024, type: F64, loc }, F64), stdoutMode: modeCall(1), stderrMode: modeCall(2), }; diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process.ts b/packages/compiler/src/frontend/lowering/builtins/child-process.ts index ea2dd379d4..b9166acee1 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process.ts @@ -731,10 +731,9 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr * 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. */ + * `encoding: "utf8"` and a numeric `maxBuffer` (Node's 1 MiB default + * otherwise, validated by the runtime) are accepted. Other options remain fenced until their + * lifecycle can share this asynchronous core. */ export function lowerExecFileCall( lowerer: Lowerer, expr: ts.CallExpression, @@ -773,6 +772,8 @@ export function lowerExecFileCall( expr.arguments.nodes.length >= 3 ? expr.arguments.nodes[1] : undefined, loc, ); + // Node's default maxBuffer for exec and execFile. + let maxBuffer: IrExpr = numLit(1024 * 1024, loc); const optionsNode = expr.arguments.nodes.length === (shell ? 3 : 4) ? expr.arguments.nodes[shell ? 1 : 2] @@ -786,18 +787,6 @@ export function lowerExecFileCall( "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.nodes) { const member = optionMember(property); if (!member) @@ -818,18 +807,12 @@ export function lowerExecFileCall( ); } } else if (member.name === "maxBuffer") { - if (!pureNumber(member.value)) { - lowerer.noLowering( - `${what} with a non-literal maxBuffer`, - member.value, - "pass a numeric literal or literal arithmetic (the native capture grows without enforcing this limit)", - ); - } + maxBuffer = lowerer.lowerExprExpecting(member.value, F64); } else { lowerer.noLowering( `${what} option '${member.name}'`, property, - "only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form", + "only encoding: 'utf8' and maxBuffer are supported in the callback form", ); } } @@ -881,7 +864,7 @@ export function lowerExecFileCall( return { kind: "libCall", fn: "cp.execFile", - args: [cmd, argv, callback, boolLit(shell, loc)], + args: [cmd, argv, callback, boolLit(shell, loc), maxBuffer], type: CHILD_T, loc, }; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index be2207bcd1..b5dd6e0c53 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -3036,7 +3036,7 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record< child_process: { execFile: "the callback forms execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback) lower; " + - "maxBuffer is not enforced by the growing native capture; other options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice", + "maxBuffer applies per stream as in Node; other options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice", }, crypto: { ...Object.fromEntries( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 142b1e25c1..c1980ca454 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -453,6 +453,13 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "net.connectLookup", "cp.execSync", "cp.execCapture", + // execFile/exec's callback form, the promisified forms' pre-call check + // and spawnSync throw Node's ERR_OUT_OF_RANGE for an invalid timeout or + // maxBuffer before anything spawns. + "cp.execFile", + "cp.execValidate", + "cp.spawnSyncOpts", + "cp.spawnSyncStdioStr", // The client request constructors validate the method token // synchronously (Node's ClientRequest ctor throw: // ERR_INVALID_HTTP_TOKEN). diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 99ca04ae4c..f6c98fe061 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2443,9 +2443,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * see scr_child.h). Throws Node's exact errors: "Command failed: " * (+ captured stderr) on non-zero exit or signal death, "spawnSync * ENOENT" on spawn failure, "spawnSync ETIMEDOUT" after - * the SIGTERM timeout, with Node's status, signal, output, pid, stdout - * and stderr properties (Buffer outputs when buffers is set: no - * encoding option). Result is the captured utf8 stdout (+1). */ + * the SIGTERM timeout, "spawnSync ENOBUFS" when the outputs + * together pass maxBuffer (the last argument; 0 means no limit), with + * Node's status, signal, output, pid, stdout and stderr properties + * (Buffer outputs when buffers is set: no encoding option). Result is + * the captured utf8 stdout (+1). */ "cp.execSync": { argTypes: [ STRING, @@ -2460,6 +2462,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ F64, F64, BOOL, + F64, ], result: STRING, }, @@ -2473,11 +2476,18 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * ScrSpawnRes container (+1; stdout/stderr strings, status unused). * Called only from the frontend's interned %execFileAsync and * %execAsync ASYNC helpers, whose fiber turns the throw into the - * rejection. */ + * rejection. The last argument is maxBuffer: a stream past it is cut + * there, the child is killed and the capture fails with Node's + * ERR_CHILD_PROCESS_STDIO_MAXBUFFER. */ "cp.execCapture": { - argTypes: [STRING, arrayOf(STRING), BOOL, STRING, BOOL, arrayOf(STRING), F64], + argTypes: [STRING, arrayOf(STRING), BOOL, STRING, BOOL, arrayOf(STRING), F64, F64], result: SPAWNRES_T, }, + /** Node's validateTimeout then validateMaxBuffer (timeout, maxBuffer), + * run before the %execFileAsync/%execAsync helper call: the promisified + * forms call execFile synchronously, so these throw instead of + * rejecting. */ + "cp.execValidate": { argTypes: [F64, F64], result: VOID }, /** child_process.spawn (scr_child.c + the scr_async.c loop): posix_spawnp * with per-slot ignore/inherit/pipe modes (and number output fds), the * all-piped Node default, and the child registered with the event loop, @@ -2532,9 +2542,11 @@ 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. 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 }, + * by the validator/backends. Then exec's shell flag (the argv is + * ["-c", command] and failure messages show the command) and maxBuffer + * (a stream past it is cut there, the child is killed and the callback + * gets Node's ERR_CHILD_PROCESS_STDIO_MAXBUFFER). */ + "cp.execFile": { argTypes: [STRING, arrayOf(STRING), null, BOOL, F64], 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 0593915903..093abc52a4 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -966,6 +966,7 @@ test("library callbacks retain child, specialized, and generic result diagnostic { kind: "arrayLit", elems: [], type: arrayOf(STRING), loc }, { kind: "boolLit", value: true, type: F64, loc }, { kind: "boolLit", value: false, type: BOOL, loc }, + { kind: "numLit", value: 1024 * 1024, type: F64, loc }, ], }; expect(validateModule(expressionModule(expr, [])).map((error) => error.message)).toEqual([ diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index b7cb3ab942..0eb25ab9a2 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -399,7 +399,7 @@ "name": "child_process.execFile", "status": "unsupported", "code": "SC2020", - "note": "the callback forms execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback) lower; maxBuffer is not enforced by the growing native capture; other options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice" + "note": "the callback forms execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback) lower; maxBuffer applies per stream as in Node; other options and reached no-callback calls remain fenced, while util.promisify(execFile) retains its wider options slice" }, { "id": "node-builtin.child_process.execFileSync", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5637-child-max-buffer-sync.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5637-child-max-buffer-sync.ts.json new file mode 100644 index 0000000000..6b40828372 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5637-child-max-buffer-sync.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5637-child-max-buffer-sync.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5638-child-max-buffer-async.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5638-child-max-buffer-async.ts.json new file mode 100644 index 0000000000..8ab722dbfc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5638-child-max-buffer-async.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5638-child-max-buffer-async.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5641-child-max-buffer-fractional.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5641-child-max-buffer-fractional.ts.json new file mode 100644 index 0000000000..bfa319327c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5641-child-max-buffer-fractional.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5641-child-max-buffer-fractional.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-emit-warning-self-replace.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-emit-warning-self-replace.ts.json new file mode 100644 index 0000000000..1e4eaaa771 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-emit-warning-self-replace.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6490-emit-warning-self-replace.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index ec4dad794f..6c9cd2889f 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -846,7 +846,9 @@ void scr_process_set_emit_warning(ScrDyn *fn) { /* A runtime-built warning while a replacement is installed: Node's modules * call process.emitWarning(message, type, code), so the replacement sees - * those arguments; nothing is reported. A throw stays pending. */ + * those arguments; nothing is reported. A throw stays pending. The call + * holds its own reference to the replacement, which may assign a new one + * while it runs. */ static void scr_emit_warning_replaced(ScrStr *message, ScrStr *type, ScrStr *code) { ScrDyn *args[3]; size_t argc = 0; @@ -855,8 +857,10 @@ static void scr_emit_warning_replaced(ScrStr *message, ScrStr *type, ScrStr *cod args[argc++] = scr_dyn_new_str(type ? type : fallback); scr_str_release(fallback); if (code) args[argc++] = scr_dyn_new_str(code); - ScrDyn *r = scr_dyn_call(scr_emit_warning_override, args, argc, "process.emitWarning"); + ScrDyn *fn = scr_dyn_retain(scr_emit_warning_override); + ScrDyn *r = scr_dyn_call(fn, args, argc, "process.emitWarning"); scr_dyn_release(r); + scr_dyn_release(fn); for (size_t i = 0; i < argc; i++) scr_dyn_release(args[i]); } #endif /* SCR_WARNING_REPLACE */ @@ -1005,8 +1009,11 @@ static void scr_warn_bad_arg(const char *arg, const char *want) { void scr_process_emit_warning(ScrDyn *args) { #ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { - ScrDyn *r = scr_dyn_apply(scr_emit_warning_override, args, "process.emitWarning"); + /* Held for the call, like scr_emit_warning_replaced. */ + ScrDyn *fn = scr_dyn_retain(scr_emit_warning_override); + ScrDyn *r = scr_dyn_apply(fn, args, "process.emitWarning"); scr_dyn_release(r); + scr_dyn_release(fn); return; } #endif diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index ec685656dc..b58c12ab24 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -175,6 +175,106 @@ static void scr_child_error_sync_fields(ScrError *e, bool has_status, double sta scr_error_set_prop(e, "stderr", 6, err); } +/* ── maxBuffer (lib/child_process.js and spawn_sync.cc) ──────────────── + * Node's default for exec, execFile and the sync forms is 1024 * 1024. */ +#define SCR_EXEC_MAX_BUFFER_DEFAULT 1048576.0 + +/* The bytes at the end of `data` that start a UTF-8 sequence the data + * does not complete (0-3): the part a StringDecoder holds back until the + * next chunk. */ +static size_t scr_utf8_incomplete_tail(const uint8_t *data, size_t len) { + for (size_t back = 1; back <= 3 && back <= len; back++) { + uint8_t b = data[len - back]; + if ((b & 0xC0) == 0x80) continue; + size_t need = (b & 0xE0) == 0xC0 ? 2 : (b & 0xF0) == 0xE0 ? 3 : (b & 0xF8) == 0xF0 ? 4 : 1; + return need > back ? back : 0; + } + return 0; +} + +/* validateTimeout and validateMaxBuffer, in Node's order: a timeout that + * is not a non-negative integer, or a negative or NaN maxBuffer, throws + * Node's ERR_OUT_OF_RANGE before anything spawns. An absent timeout + * arrives as 0. */ +static bool scr_exec_options_valid(double timeout_ms, double max_buffer) { + const char *name, *range; + double value; + if (!(timeout_ms >= 0 && timeout_ms <= 9007199254740991.0 && timeout_ms == floor(timeout_ms))) { + name = "timeout"; + range = "an unsigned integer"; + value = timeout_ms; + } else if (!(max_buffer >= 0)) { + name = "options.maxBuffer"; + range = "a positive number"; + value = max_buffer; + } else { + return true; + } + char received[48]; + scr_num_received(value, received); + char msg[160]; + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be %s. Received %s", name, + range, received); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; +} + +void scr_exec_validate(double timeout_ms, double max_buffer) { + (void)scr_exec_options_valid(timeout_ms, max_buffer); +} + +/* One chunk of an exec/execFile capture under Node's per-stream maxBuffer + * check. The chunk was appended to data[chunk_start..*len). Node decodes + * each chunk as utf8, adds its byte length to the stream's total + * (*text_len), and once the total exceeds maxBuffer keeps only + * `maxBuffer - previous total` UTF-16 code units of that chunk's text. On + * that overflow the capture is cut accordingly (*len shrinks; a split + * surrogate pair keeps its high half as WTF-8) and the result is true. */ +static bool scr_exec_limit_chunk(uint8_t *data, size_t *len, size_t chunk_start, + double *text_len, double max_buffer) { + size_t carried = scr_utf8_incomplete_tail(data, chunk_start); + size_t text_start = chunk_start - carried; + size_t text_end = *len - scr_utf8_incomplete_tail(data, *len); + if (text_end < text_start) text_end = text_start; + double chunk_len = (double)(text_end - text_start); + double before = *text_len; + *text_len = before + chunk_len; + if (!(*text_len > max_buffer)) return false; + /* Node slices the chunk at `maxBuffer - previous total`, and a slice end + * is truncated to an integer: a fractional limit keeps only whole units. + * The previous total was within the limit, so `keep` is not negative and + * below the chunk's length. */ + double keep = floor(max_buffer - before); + double units = 0; + size_t at = text_start; + while (at < text_end && units < keep) { + uint8_t b = data[at]; + size_t n = (b & 0xE0) == 0xC0 ? 2 : (b & 0xF0) == 0xE0 ? 3 : (b & 0xF8) == 0xF0 ? 4 : 1; + if (at + n > text_end) n = 1; + double u = n == 4 ? 2 : 1; + if (units + u <= keep) { + at += n; + units += u; + continue; + } + /* Exactly one unit left inside a complete astral character: Node's + * slice keeps the lone high surrogate. */ + if (n != 4 || keep - units != 1) break; + uint32_t cp = ((uint32_t)(b & 0x07) << 18) | ((uint32_t)(data[at + 1] & 0x3F) << 12) | + ((uint32_t)(data[at + 2] & 0x3F) << 6) | (uint32_t)(data[at + 3] & 0x3F); + uint32_t hi = 0xD800 + ((cp - 0x10000) >> 10); + data[at] = 0xED; + data[at + 1] = (uint8_t)(0xA0 | ((hi >> 6) & 0x0F)); + data[at + 2] = (uint8_t)(0x80 | (hi & 0x3F)); + scr_wtf8_note(); + at += 3; + break; + } + *len = at; + return true; +} + #ifdef _WIN32 /* ── Windows arm: real children via CreateProcessW ──────────────────── * The POSIX arm below owns the corresponding implementation; this arm @@ -880,6 +980,8 @@ typedef struct { bool timed_out; /* the deadline TerminateProcess fired */ DWORD exit_code; DWORD pid; /* the child's process id (0 when it never ran) */ + bool enobufs; /* the outputs together passed maxBuffer */ + const char *over; /* per-stream maxBuffer: the stream that passed it */ ScrCapBuf out, err; /* always initialized; caller frees */ } ScrWinSyncRes; @@ -890,7 +992,10 @@ typedef struct { static void scr_win_run_sync(ScrStr *cmd, ScrArr *args, const ScrStr *input, int in_mode, int out_mode, int err_mode, const ScrStr *cwd, ScrArr *env_pairs, - double timeout_ms, ScrWinSyncRes *res) { + double timeout_ms, double max_buffer, bool per_stream, + ScrWinSyncRes *res) { + res->enobufs = false; + res->over = NULL; res->spawn_errname = NULL; res->timed_out = false; res->exit_code = 0; @@ -970,11 +1075,28 @@ static void scr_win_run_sync(ScrStr *cmd, ScrArr *args, const ScrStr *input, char *input_owned = NULL; const char *input_bytes = in_open ? scr_str_utf8(input, &input_owned) : NULL; bool exited = false; + double out_text = 0, err_text = 0; double deadline = timeout_ms > 0 ? scr_now_ms() + timeout_ms : 0; for (;;) { bool progress = false; + bool kill_now = false; + size_t out_before = res->out.len, err_before = res->err.len; if (out_open) progress |= scr_win_cap_pump(out_parent, &res->out, &out_open); if (err_open) progress |= scr_win_cap_pump(err_parent, &res->err, &err_open); + /* maxBuffer: per stream with exec's cut (the async shape), or both + * streams together, 0 meaning no limit (spawn_sync.cc). */ + if (per_stream) { + if (scr_exec_limit_chunk((uint8_t *)res->out.data, &res->out.len, out_before, &out_text, + max_buffer)) + res->over = "stdout"; + else if (scr_exec_limit_chunk((uint8_t *)res->err.data, &res->err.len, err_before, + &err_text, max_buffer)) + res->over = "stderr"; + kill_now = res->over != NULL; + } else if (max_buffer > 0 && (double)(res->out.len + res->err.len) > max_buffer) { + res->enobufs = true; + kill_now = true; + } if (in_open) { /* Small chunks: PIPE_NOWAIT's partial-write behavior is only * dependable for writes no larger than the pipe buffer. */ @@ -998,9 +1120,18 @@ static void scr_win_run_sync(ScrStr *cmd, ScrArr *args, const ScrStr *input, in_open = false; } } - if (deadline > 0 && !res->timed_out && scr_now_ms() >= deadline) { + if (deadline > 0 && !res->timed_out && !kill_now && scr_now_ms() >= deadline) { res->timed_out = true; + kill_now = true; + } + if (kill_now) { + /* Node kills the child and closes every stdio pipe, so a grandchild + * holding one cannot keep the call waiting. */ (void)TerminateProcess(sp.proc, 1); /* killed children exit 1 (uv) */ + if (out_open) CloseHandle(out_parent); + if (err_open) CloseHandle(err_parent); + if (in_open) CloseHandle(in_parent); + out_open = err_open = in_open = false; } if (!exited) { exited = WaitForSingleObject(sp.proc, progress ? 0 : 1) == WAIT_OBJECT_0; @@ -1108,7 +1239,7 @@ static ScrSpawnRes *scr_spawn_sync_core(ScrStr *cmd, ScrArr *args, double timeou int err_mode) { ScrWinSyncRes w; scr_win_run_sync(cmd, args, NULL, in_mode, out_mode, err_mode, NULL, NULL, - timeout_ms, &w); + timeout_ms, SCR_EXEC_MAX_BUFFER_DEFAULT, false, &w); if (w.spawn_errname != NULL) { free(w.out.data); free(w.err.data); @@ -1121,16 +1252,16 @@ static ScrSpawnRes *scr_spawn_sync_core(ScrStr *cmd, ScrArr *args, double timeou ScrStr *err_s = scr_str_new(w.err.data, w.err.len); free(w.out.data); free(w.err.data); - if (!w.timed_out) { + if (!w.timed_out && !w.enobufs) { return scr_spawn_res_new(true, (double)w.exit_code, out_s, err_s); } - /* Node's timeout shape: status null, signal = killSignal's name, error - * ETIMEDOUT (the kill may have raced a normal exit; Node reports the - * timeout either way). */ + /* Node's timeout and maxBuffer shape: status null, signal = killSignal's + * name, error ETIMEDOUT or ENOBUFS (the kill may have raced a normal + * exit; Node reports the error either way). */ ScrSpawnRes *r = scr_spawn_res_new(false, 0, out_s, err_s); r->signal_name = scr_signal_name(killsig); if (r->signal_name == NULL) r->signal_name = "SIGTERM"; - r->spawn_errname = "ETIMEDOUT"; + r->spawn_errname = w.timed_out ? "ETIMEDOUT" : "ENOBUFS"; r->cmd = scr_str_retain(cmd); return r; } @@ -1142,6 +1273,8 @@ ScrSpawnRes *scr_spawn_sync(ScrStr *cmd, ScrArr *args) { ScrSpawnRes *scr_spawn_sync_opts(ScrStr *cmd, ScrArr *args, double timeout_ms, ScrStr *killsignal, double in_mode, double out_mode, double err_mode) { + /* spawnSync's validateTimeout (its maxBuffer is the 1 MiB default). */ + if (!scr_exec_options_valid(timeout_ms, 0)) return NULL; int killsig = SIGTERM; if (killsignal->len > 0) { int resolved = scr_signal_from_name(killsignal); @@ -1246,7 +1379,8 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, const ScrStr *input, const ScrStr *cwd, ScrArr *env_pairs, double timeout_ms, int stdout_mode, int stderr_mode, bool async_shape, - bool buffers, ScrStr **stderr_out) { + bool buffers, double max_buffer, ScrStr **stderr_out) { + if (!scr_exec_options_valid(timeout_ms, max_buffer)) return NULL; /* stdio "inherit"'s stdin rides bit 4 of stdout_mode (scr_runtime.h). */ bool stdin_inherit = (stdout_mode & 4) != 0; stdout_mode &= 3; @@ -1256,7 +1390,7 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, scr_win_run_sync(cmd, args, input, stdin_inherit ? 2 : 0, cap_out ? 0 : (stdout_mode == 2 ? 2 : 1), cap_err ? 0 : (stderr_mode == 3 ? 2 : 1), cwd, - env_pairs, timeout_ms, &w); + env_pairs, timeout_ms, max_buffer, async_shape, &w); if (w.spawn_errname != NULL) { free(w.out.data); free(w.err.data); @@ -1282,11 +1416,11 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, fflush(stdout); fwrite(w.err.data, 1, w.err.len, stderr); } - if (w.timed_out && !async_shape) { - /* The SYNC forms throw Node's spawnSync ETIMEDOUT; the async shape - * reports the kill's death as an ordinary command failure below, + if ((w.timed_out || w.enobufs) && !async_shape) { + /* The SYNC forms throw Node's spawnSync ETIMEDOUT or ENOBUFS; the async + * shape reports the kill's death as an ordinary command failure below, * exactly Node's promisified execFile. */ - ScrError *e = scr_exec_spawn_error(cmd, args, "ETIMEDOUT", false); + ScrError *e = scr_exec_spawn_error(cmd, args, w.timed_out ? "ETIMEDOUT" : "ENOBUFS", false); scr_child_error_self(e); scr_child_error_sync_fields(e, false, 0, "SIGTERM", (double)w.pid, true, scr_child_sync_output(w.out.data, w.out.len, cap_out, buffers), @@ -1296,6 +1430,28 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, scr_exec_throw(e); return NULL; } + if (w.over != NULL) { + /* exec's maxBuffer error: a RangeError with Node's code, then cmd and + * the outputs as cut. */ + char msg[48]; + int len = snprintf(msg, sizeof msg, "%s maxBuffer length exceeded", w.over); + ScrStr *message = scr_str_new(msg, (size_t)len); + ScrError *e = scr_error_new(SCR_ERR_RANGE, message); + scr_str_release(message); + scr_error_set_code(e, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); + char *display = scr_exec_display(cmd, args, shell); + scr_child_error_cmd(e, display); + free(display); + ScrStr *out_s = scr_str_new(w.out.data, w.out.len); + ScrStr *err_s = scr_str_new(w.err.data, w.err.len); + scr_child_error_outputs(e, out_s, err_s); + scr_str_release(out_s); + scr_str_release(err_s); + free(w.out.data); + free(w.err.data); + scr_exec_throw(e); + return NULL; + } bool failed = w.exit_code != 0 || w.timed_out; if (failed) { char *display = scr_exec_display(cmd, args, shell); @@ -1335,18 +1491,20 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, bool has_input, ScrStr *cwd, bool has_env, ScrArr *env_pairs, double timeout_ms, double stdout_mode, double stderr_mode, - bool buffers) { + bool buffers, double max_buffer) { return scr_exec_run(cmd, args, shell, has_input ? input : NULL, cwd->len > 0 ? cwd : NULL, has_env ? env_pairs : NULL, - timeout_ms, (int)stdout_mode, (int)stderr_mode, false, buffers, NULL); + timeout_ms, (int)stdout_mode, (int)stderr_mode, false, buffers, + max_buffer, NULL); } ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd, - bool has_env, ScrArr *env_pairs, double timeout_ms) { + bool has_env, ScrArr *env_pairs, double timeout_ms, + double max_buffer) { ScrStr *err_s = NULL; ScrStr *out_s = scr_exec_run(cmd, args, shell, NULL, cwd->len > 0 ? cwd : NULL, has_env ? env_pairs : NULL, timeout_ms, 1, 1, - true, false, &err_s); + true, false, max_buffer, &err_s); if (out_s == NULL) return NULL; /* exception pending; dummy result */ return scr_spawn_res_new(true, 0, out_s, err_s); } @@ -1400,6 +1558,16 @@ struct ScrChildStream { double dec_pending; uint8_t *capture_data; size_t capture_len, capture_cap; + /* exec's maxBuffer: the limit, the decoded byte count so far, the + * child to kill when the capture passes the limit, and the check of + * each appended chunk (both NULL once the exec callback fired or outside + * exec); capture_stopped ends the capture. */ + double capture_max; + double capture_text_len; + struct ScrChild *capture_child; + void (*capture_check)(struct ScrChildStream *s, size_t chunk_start); + bool capture_stopped; + bool destroyed; /* exec's maxBuffer kill: ends without 'end', reads nothing more */ ScrChildStreamDataL *data_ls; size_t n_data, cap_data; ScrChildStreamEndL *end_ls; @@ -1473,6 +1641,7 @@ struct ScrChild { ScrStr *exec_file; /* the spawned file and its arguments (+1 each), for */ ScrArr *exec_args; /* a spawn failure's path and spawnargs */ void (*exec_props)(struct ScrChild *c, ScrError *error); /* Node's error properties */ + ScrError *exec_over; /* exec's maxBuffer RangeError once a capture passed the limit (+1) */ ScrChildStream *out_stream; ScrChildStream *err_stream; ScrChildWriter *in_stream; @@ -1527,6 +1696,7 @@ void scr_child_release(ScrChild *c) { scr_child_drop_listeners(c); /* only reachable pre-settle via leaks */ scr_str_release(c->err_msg); scr_str_release(c->exec_display); + if (c->exec_over) scr_error_release(c->exec_over); scr_str_release(c->exec_file); if (c->exec_args) scr_arr_release(c->exec_args); scr_dyn_release(c->out_dyn); @@ -1613,7 +1783,8 @@ static void scr_child_stream_close_wait(ScrChildStream *s) { static void scr_child_stream_capture_append(ScrChildStream *s, const char *data, size_t len) { - if (!s->capture || len == 0) return; + if (!s->capture || s->capture_stopped || len == 0) return; + size_t start = s->capture_len; size_t need = s->capture_len + len; if (need > s->capture_cap) { size_t cap = s->capture_cap ? s->capture_cap : 256; @@ -1625,6 +1796,7 @@ static void scr_child_stream_capture_append(ScrChildStream *s, const char *data, } memcpy(s->capture_data + s->capture_len, data, len); s->capture_len = need; + if (s->capture_check != NULL) s->capture_check(s, start); } static ScrStr *scr_child_stream_capture_value(ScrChildStream *s) { @@ -1677,6 +1849,7 @@ static void scr_child_stream_emit(ScrChildStream *s, ScrBytes *bytes, } static void scr_child_stream_finish(ScrChildStream *s, bool fire_end) { + if (s->destroyed) fire_end = false; if (fire_end && s->encoding != NULL && s->n_data > 0) { ScrStr *tail = scr_strdec_end(s->encoding, s->dec_pending); s->dec_pending = 0; @@ -1714,6 +1887,7 @@ static void scr_child_stream_finish(ScrChildStream *s, bool fire_end) { * (broken pipe: every writer closed). Returns true at EOF: the caller * finishes and unlinks. */ static bool scr_child_stream_pump(ScrChildStream *s) { + if (s->destroyed) return true; while (scr_child_stream_watching(s)) { DWORD avail = 0; if (!PeekNamedPipe(s->h, NULL, 0, NULL, &avail, NULL)) return true; @@ -1723,6 +1897,7 @@ static bool scr_child_stream_pump(ScrChildStream *s) { DWORD got = 0; if (!ReadFile(s->h, buf, avail, &got, NULL) || got == 0) return true; scr_child_stream_capture_append(s, buf, (size_t)got); + if (s->destroyed) return true; if (s->n_data == 0) continue; ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)got); chunk->is_buffer = true; /* Node delivers a Buffer */ @@ -2430,6 +2605,11 @@ static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { if (c->exec_props) c->exec_props(c, error); return error; } + if (c->exec_over != NULL) { + ScrError *error = c->exec_over; /* moves */ + c->exec_over = NULL; + return error; + } if (c->has_code && c->code == 0) return NULL; size_t cap = 16 + c->exec_display->len + 1 + stderr_value->len + 1; char *msg = malloc(cap); @@ -2446,8 +2626,48 @@ static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { return error; } +static bool scr_child_kill_signo(ScrChild *c, int signo); + +/* exec's maxBuffer kill: the callback's RangeError (code, then cmd) is + * built now, both streams are destroyed and the child gets the kill signal + * (lib/child_process.js), so a grandchild holding a pipe cannot keep the + * loop waiting. The next pump of each stream closes it. */ +static void scr_child_stream_capture_over(ScrChildStream *s) { + ScrChild *c = s->capture_child; + char msg[48]; + int len = snprintf(msg, sizeof msg, "%s maxBuffer length exceeded", + s == c->out_stream ? "stdout" : "stderr"); + ScrStr *message = scr_str_new(msg, (size_t)len); + c->exec_over = scr_error_new(SCR_ERR_RANGE, message); + scr_str_release(message); + scr_error_set_code(c->exec_over, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); + scr_child_error_cmd(c->exec_over, c->exec_display->data); + ScrChildStream *streams[2] = {c->out_stream, c->err_stream}; + for (int i = 0; i < 2; i++) { + if (streams[i] == NULL) continue; + streams[i]->capture_stopped = true; + streams[i]->destroyed = true; + } + (void)scr_child_kill_signo(c, SIGTERM); +} + +/* exec's maxBuffer check of one appended chunk. scr_exec_file installs it + * per stream, so programs without the exec callback forms link none of it + * (every program with an event loop links the capture path). */ +static void scr_exec_capture_check(ScrChildStream *s, size_t chunk_start) { + if (scr_exec_limit_chunk(s->capture_data, &s->capture_len, chunk_start, &s->capture_text_len, + s->capture_max)) + scr_child_stream_capture_over(s); +} + static void scr_exec_file_fire(ScrChild *c) { if (c->exec_cb == NULL || c->exec_fn == NULL) return; + ScrChildStream *streams[2] = {c->out_stream, c->err_stream}; + for (int i = 0; i < 2; i++) { + if (streams[i] == NULL) continue; + streams[i]->capture_child = NULL; + streams[i]->capture_check = NULL; + } ScrStr *out = scr_child_stream_capture_value(c->out_stream); ScrStr *err = scr_child_stream_capture_value(c->err_stream); ScrError *error = scr_exec_file_error(c, err); @@ -2455,7 +2675,11 @@ static void scr_exec_file_fire(ScrChild *c) { } ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, - ScrExecFileFn fn, bool shell) { + ScrExecFileFn fn, bool shell, double max_buffer) { + if (!scr_exec_options_valid(0, max_buffer)) { + scr_closure_release(cb); + return NULL; + } ScrStr *empty = scr_str_new("", 0); ScrChild *c = scr_spawn_opts(cmd, args, 3, 3, 3, 0, 0, false, false, false, NULL, empty); @@ -2470,6 +2694,13 @@ ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, free(display); scr_child_stream_capture_begin(c->out_stream); scr_child_stream_capture_begin(c->err_stream); + ScrChildStream *streams[2] = {c->out_stream, c->err_stream}; + for (int i = 0; i < 2; i++) { + if (streams[i] == NULL) continue; + streams[i]->capture_max = max_buffer; + streams[i]->capture_child = c; + streams[i]->capture_check = &scr_exec_capture_check; + } return c; } @@ -3076,6 +3307,12 @@ static bool scr_cap_read(ScrCapBuf *b, int fd) { return true; } +/* Closes a capture pipe's read end once (-1 afterwards). */ +static void scr_exec_close_fd(int *fd) { + if (*fd >= 0) close(*fd); + *fd = -1; +} + /* ── spawnSync ───────────────────────────────────────────────────────── */ static ScrSpawnRes *scr_spawn_res_new(bool has_status, double status, @@ -3164,6 +3401,7 @@ static ScrSpawnRes *scr_spawn_sync_core(ScrStr *cmd, ScrArr *args, double timeou scr_cap_init(&err); bool out_open = cap_out, err_open = cap_err; bool timed_out = false; + bool enobufs = false; /* the outputs together passed Node's default maxBuffer */ double deadline = timeout_ms > 0 ? scr_now_ms() + timeout_ms : 0; while (out_open || err_open) { struct pollfd fds[2]; @@ -3180,19 +3418,30 @@ static ScrSpawnRes *scr_spawn_sync_core(ScrStr *cmd, ScrArr *args, double timeou if (errno == EINTR) continue; break; } + bool kill_now = false; if (deadline > 0 && !timed_out && scr_now_ms() >= deadline) { timed_out = true; - kill(pid, killsig); + kill_now = true; } - if (rc == 0) continue; - for (nfds_t i = 0; i < n; i++) { + for (nfds_t i = 0; rc > 0 && !kill_now && i < n; i++) { if (!(fds[i].revents & (POLLIN | POLLHUP | POLLERR))) continue; if (out_open && fds[i].fd == outfd[0]) out_open = scr_cap_read(&out, outfd[0]); else if (err_open && fds[i].fd == errfd[0]) err_open = scr_cap_read(&err, errfd[0]); + if ((double)(out.len + err.len) > SCR_EXEC_MAX_BUFFER_DEFAULT) { + enobufs = true; + kill_now = true; + } + } + if (kill_now) { + /* Node kills the child and closes every stdio pipe (spawn_sync.cc). */ + kill(pid, killsig); + scr_exec_close_fd(&outfd[0]); + scr_exec_close_fd(&errfd[0]); + out_open = err_open = false; } } - if (cap_out) close(outfd[0]); - if (cap_err) close(errfd[0]); + scr_exec_close_fd(&outfd[0]); + scr_exec_close_fd(&errfd[0]); /* Reap. With nothing captured the deadline lives here: WNOHANG-poll * until the child exits or the timeout kill fires (the exec core's @@ -3223,18 +3472,18 @@ static ScrSpawnRes *scr_spawn_sync_core(ScrStr *cmd, ScrArr *args, double timeou free(out.data); free(err.data); ScrSpawnRes *r; - if (WIFEXITED(st) && !timed_out) { + if (WIFEXITED(st) && !timed_out && !enobufs) { r = scr_spawn_res_new(true, (double)WEXITSTATUS(st), out_s, err_s); } else { /* Killed by a signal (the timeout's included): Node's status null, * signal = the terminating signal's name. */ r = scr_spawn_res_new(false, 0, out_s, err_s); if (WIFSIGNALED(st)) r->signal_name = scr_signal_name(WTERMSIG(st)); - if (timed_out) { - /* Node: error ETIMEDOUT + signal = killSignal (the kill may have - * raced a normal exit; Node reports the timeout either way). */ + if (timed_out || enobufs) { + /* Node: error ETIMEDOUT or ENOBUFS + signal = killSignal (the kill + * may have raced a normal exit; Node reports the error either way). */ if (r->signal_name == NULL) r->signal_name = scr_signal_name(killsig); - r->spawn_errname = "ETIMEDOUT"; + r->spawn_errname = timed_out ? "ETIMEDOUT" : "ENOBUFS"; r->cmd = scr_str_retain(cmd); } } @@ -3252,6 +3501,8 @@ ScrSpawnRes *scr_spawn_sync(ScrStr *cmd, ScrArr *args) { ScrSpawnRes *scr_spawn_sync_opts(ScrStr *cmd, ScrArr *args, double timeout_ms, ScrStr *killsignal, double in_mode, double out_mode, double err_mode) { + /* spawnSync's validateTimeout (its maxBuffer is the 1 MiB default). */ + if (!scr_exec_options_valid(timeout_ms, 0)) return NULL; int killsig = SIGTERM; if (killsignal->len > 0) { int resolved = scr_signal_from_name(killsignal); @@ -3316,6 +3567,9 @@ typedef struct { bool async_shape; /* The sync forms' error outputs are Buffers (no encoding option). */ bool buffers; + /* maxBuffer: the async shape limits each stream, the sync forms both + * together (0 meaning no limit). */ + double max_buffer; /* async_shape success: the captured stderr string (+1) lands here. */ ScrStr **stderr_out; } ScrExecOpts; @@ -3429,7 +3683,19 @@ static void scr_exec_throw(ScrError *e /* moves */) { scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } +/* After a read into buf (which held `before` bytes): whether the capture + * just passed maxBuffer. The async shape limits each stream and cuts it + * where Node's exec does; the sync forms limit both streams together, 0 + * meaning no limit (spawn_sync.cc). */ +static bool scr_exec_capture_over(const ScrExecOpts *o, ScrCapBuf *buf, size_t before, + double *text_len, const ScrCapBuf *other) { + if (o->async_shape) + return scr_exec_limit_chunk((uint8_t *)buf->data, &buf->len, before, text_len, o->max_buffer); + return o->max_buffer > 0 && (double)(buf->len + other->len) > o->max_buffer; +} + ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { + if (!scr_exec_options_valid(o->timeout_ms, o->max_buffer)) return NULL; int outfd[2] = {-1, -1}, errfd[2] = {-1, -1}, infd[2] = {-1, -1}; bool cap_out = o->stdout_mode == 1; bool cap_err = o->stderr_mode == 0 || o->stderr_mode == 1; @@ -3520,6 +3786,9 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { char *input_owned = NULL; const char *input = has_input ? scr_str_utf8(o->input, &input_owned) : NULL; bool timed_out = false; + bool enobufs = false; /* the sync forms passed maxBuffer */ + const char *over = NULL; /* the async shape's stream that passed it */ + double out_text = 0, err_text = 0; double deadline = o->timeout_ms > 0 ? scr_now_ms() + o->timeout_ms : 0; while (out_open || err_open || in_open) { struct pollfd fds[3]; @@ -3537,19 +3806,34 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { if (errno == EINTR) continue; break; } + bool kill_now = false; if (deadline > 0 && !timed_out && scr_now_ms() >= deadline) { - /* Node's timeout: killSignal (SIGTERM default) to the child; the - * capture loop keeps draining until the pipes close. */ + /* Node's timeout: killSignal (SIGTERM default) to the child. */ timed_out = true; - kill(pid, SIGTERM); + kill_now = true; } - if (rc == 0) continue; - for (nfds_t i = 0; i < n; i++) { + for (nfds_t i = 0; rc > 0 && !kill_now && i < n; i++) { if (fds[i].revents == 0) continue; if (out_open && fds[i].fd == outfd[0]) { - if (fds[i].revents & (POLLIN | POLLHUP | POLLERR)) out_open = scr_cap_read(&out, outfd[0]); + if (fds[i].revents & (POLLIN | POLLHUP | POLLERR)) { + size_t before = out.len; + out_open = scr_cap_read(&out, outfd[0]); + if (scr_exec_capture_over(o, &out, before, &out_text, &err)) { + if (o->async_shape) over = "stdout"; + else enobufs = true; + kill_now = true; + } + } } else if (err_open && fds[i].fd == errfd[0]) { - if (fds[i].revents & (POLLIN | POLLHUP | POLLERR)) err_open = scr_cap_read(&err, errfd[0]); + if (fds[i].revents & (POLLIN | POLLHUP | POLLERR)) { + size_t before = err.len; + err_open = scr_cap_read(&err, errfd[0]); + if (scr_exec_capture_over(o, &err, before, &err_text, &out)) { + if (o->async_shape) over = "stderr"; + else enobufs = true; + kill_now = true; + } + } } else if (in_open && fds[i].fd == infd[1]) { if (fds[i].revents & (POLLERR | POLLHUP)) { close(infd[1]); @@ -3568,9 +3852,19 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { } } } + if (kill_now) { + /* Node kills the child and closes every stdio pipe, so a grandchild + * holding one cannot keep the call waiting (spawn_sync.cc's Kill and + * exec's kill, which destroys both streams). */ + kill(pid, SIGTERM); + scr_exec_close_fd(&outfd[0]); + scr_exec_close_fd(&errfd[0]); + if (in_open) close(infd[1]); + out_open = err_open = in_open = false; + } } - if (cap_out) close(outfd[0]); - if (cap_err) close(errfd[0]); + scr_exec_close_fd(&outfd[0]); + scr_exec_close_fd(&errfd[0]); if (in_open) close(infd[1]); scr_str_utf8_end(input_owned); @@ -3607,12 +3901,12 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { } bool exited = WIFEXITED(st) && !(timed_out && !o->async_shape); const char *signal = WIFSIGNALED(st) ? scr_signal_name(WTERMSIG(st)) : NULL; - if (timed_out && !o->async_shape) { - /* The SYNC forms throw Node's spawnSync ETIMEDOUT (signal = the kill - * signal even when the child raced it); the async shape reports the - * SIGTERM death as an ordinary command failure below, exactly Node's - * promisified execFile. */ - ScrError *e = scr_exec_spawn_error(cmd, args, ETIMEDOUT, false); + if ((timed_out || enobufs) && !o->async_shape) { + /* The SYNC forms throw Node's spawnSync ETIMEDOUT or ENOBUFS (signal = + * the kill signal even when the child raced it); the async shape + * reports the SIGTERM death as an ordinary command failure below, + * exactly Node's promisified execFile. */ + ScrError *e = scr_exec_spawn_error(cmd, args, timed_out ? ETIMEDOUT : ENOBUFS, false); scr_child_error_self(e); scr_child_error_sync_fields(e, false, 0, signal != NULL ? signal : "SIGTERM", (double)pid, true, scr_child_sync_output(out.data, out.len, cap_out, o->buffers), @@ -3622,6 +3916,28 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { scr_exec_throw(e); return NULL; } + if (over != NULL) { + /* exec's maxBuffer error: a RangeError with Node's code, then cmd and + * the outputs as cut. */ + char msg[48]; + int len = snprintf(msg, sizeof msg, "%s maxBuffer length exceeded", over); + ScrStr *message = scr_str_new(msg, (size_t)len); + ScrError *e = scr_error_new(SCR_ERR_RANGE, message); + scr_str_release(message); + scr_error_set_code(e, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); + char *display = scr_exec_display(cmd, args, o->shell); + scr_child_error_cmd(e, display); + free(display); + ScrStr *out_s = scr_str_new(out.data, out.len); + ScrStr *err_s = scr_str_new(err.data, err.len); + scr_child_error_outputs(e, out_s, err_s); + scr_str_release(out_s); + scr_str_release(err_s); + free(out.data); + free(err.data); + scr_exec_throw(e); + return NULL; + } bool failed = !exited || WEXITSTATUS(st) != 0; if (failed) { char *display = scr_exec_display(cmd, args, o->shell); @@ -3665,7 +3981,7 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, bool has_input, ScrStr *cwd, bool has_env, ScrArr *env_pairs, double timeout_ms, double stdout_mode, double stderr_mode, - bool buffers) { + bool buffers, double max_buffer) { ScrExecOpts o = { .shell = shell, .input = has_input ? input : NULL, @@ -3678,6 +3994,7 @@ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, .stderr_mode = (int)stderr_mode, .stdin_inherit = ((int)stdout_mode & 4) != 0, .buffers = buffers, + .max_buffer = max_buffer, }; return scr_exec_sync_core(cmd, args, &o); } @@ -3689,7 +4006,8 @@ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, * the throw into the rejection). Success answers a +1 ScrSpawnRes reusing * the spawnSync result container (status unused — a failure threw). */ ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd, - bool has_env, ScrArr *env_pairs, double timeout_ms) { + bool has_env, ScrArr *env_pairs, double timeout_ms, + double max_buffer) { ScrStr *err_s = NULL; ScrExecOpts o = { .shell = shell, @@ -3700,6 +4018,7 @@ ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd .stdout_mode = 1, .stderr_mode = 1, .async_shape = true, + .max_buffer = max_buffer, .stderr_out = &err_s, }; ScrStr *out_s = scr_exec_sync_core(cmd, args, &o); @@ -3807,6 +4126,16 @@ struct ScrChildStream { double dec_pending; uint8_t *capture_data; size_t capture_len, capture_cap; + /* exec's maxBuffer: the limit, the decoded byte count so far, the + * child to kill when the capture passes the limit, and the check of + * each appended chunk (both NULL once the exec callback fired or outside + * exec); capture_stopped ends the capture. */ + double capture_max; + double capture_text_len; + struct ScrChild *capture_child; + void (*capture_check)(struct ScrChildStream *s, size_t chunk_start); + bool capture_stopped; + bool destroyed; /* exec's maxBuffer kill: ends without 'end', reads nothing more */ bool armed; /* EVFILT_READ registered on the child kqueue */ bool uncounted; /* counted into scr_children_unwatched (arm failure) */ bool short_read_yields; /* a short read ends this pass's reads */ @@ -3882,6 +4211,7 @@ struct ScrChild { ScrStr *exec_file; /* the spawned file and its arguments (+1 each), for */ ScrArr *exec_args; /* a spawn failure's path and spawnargs */ void (*exec_props)(struct ScrChild *c, ScrError *error); /* Node's error properties */ + ScrError *exec_over; /* exec's maxBuffer RangeError once a capture passed the limit (+1) */ /* Piped stdio (stdio mode 3): the stream handles child.stdout / * child.stderr answer — owned (+1 each), NULL when not piped. */ ScrChildStream *out_stream; @@ -4076,6 +4406,7 @@ void scr_child_release(ScrChild *c) { scr_child_drop_listeners(c); /* only reachable pre-settle via leaks */ scr_str_release(c->err_msg); scr_str_release(c->exec_display); + if (c->exec_over) scr_error_release(c->exec_over); scr_str_release(c->exec_file); if (c->exec_args) scr_arr_release(c->exec_args); scr_dyn_release(c->out_dyn); @@ -4109,6 +4440,7 @@ static const char *scr_errname(int err) { case EIO: return "EIO"; case EINVAL: return "EINVAL"; case ETIMEDOUT: return "ETIMEDOUT"; + case ENOBUFS: return "ENOBUFS"; default: return "EUNKNOWN"; } } @@ -4191,9 +4523,11 @@ static void scr_child_stream_close_wait(ScrChildStream *s) { } } +static bool scr_child_kill_signo(struct ScrChild *c, int signo); static void scr_child_stream_capture_append(ScrChildStream *s, const char *data, size_t len) { - if (!s->capture || len == 0) return; + if (!s->capture || s->capture_stopped || len == 0) return; + size_t start = s->capture_len; size_t need = s->capture_len + len; if (need > s->capture_cap) { size_t cap = s->capture_cap ? s->capture_cap : 256; @@ -4205,6 +4539,7 @@ static void scr_child_stream_capture_append(ScrChildStream *s, const char *data, } memcpy(s->capture_data + s->capture_len, data, len); s->capture_len = need; + if (s->capture_check != NULL) s->capture_check(s, start); } static ScrStr *scr_child_stream_capture_value(ScrChildStream *s) { @@ -4265,6 +4600,7 @@ static void scr_child_stream_emit_posix(ScrChildStream *s, ScrBytes *bytes, * closes (deleting its kqueue filter), and the liveness/fallback counters * settle. The caller unlinks from the registry and releases its ref. */ static void scr_child_stream_finish(ScrChildStream *s, bool fire_end) { + if (s->destroyed) fire_end = false; if (fire_end && s->encoding != NULL && s->n_data > 0) { ScrStr *tail = scr_strdec_end(s->encoding, s->dec_pending); s->dec_pending = 0; @@ -4308,6 +4644,7 @@ static void scr_child_stream_finish(ScrChildStream *s, bool fire_end) { * meaningfully reachable, treated as EOF like the stdin slice's * no-listener path): the caller finishes and unlinks it. */ static bool scr_child_stream_pump(ScrChildStream *s) { + if (s->destroyed) return true; if (s->short_read_yields && s->short_read_pass == scr_children_pass) return false; while (scr_child_stream_watching(s)) { char buf[65536]; @@ -4318,6 +4655,7 @@ static bool scr_child_stream_pump(ScrChildStream *s) { } if (n == 0) return true; scr_child_stream_capture_append(s, buf, (size_t)n); + if (s->destroyed) return true; if (s->n_data == 0) continue; ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); chunk->is_buffer = true; /* Node delivers a Buffer */ @@ -5133,6 +5471,38 @@ ScrChild *scr_fork(double target, ScrArr *args, double in_mode, return child; } +/* exec's maxBuffer kill: the callback's RangeError (code, then cmd) is + * built now, both streams are destroyed and the child gets the kill signal + * (lib/child_process.js), so a grandchild holding a pipe cannot keep the + * loop waiting. The next pump of each stream closes it. */ +static void scr_child_stream_capture_over(ScrChildStream *s) { + ScrChild *c = s->capture_child; + char msg[48]; + int len = snprintf(msg, sizeof msg, "%s maxBuffer length exceeded", + s == c->out_stream ? "stdout" : "stderr"); + ScrStr *message = scr_str_new(msg, (size_t)len); + c->exec_over = scr_error_new(SCR_ERR_RANGE, message); + scr_str_release(message); + scr_error_set_code(c->exec_over, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); + scr_child_error_cmd(c->exec_over, c->exec_display->data); + ScrChildStream *streams[2] = {c->out_stream, c->err_stream}; + for (int i = 0; i < 2; i++) { + if (streams[i] == NULL) continue; + streams[i]->capture_stopped = true; + streams[i]->destroyed = true; + } + (void)scr_child_kill_signo(c, SIGTERM); +} + +/* exec's maxBuffer check of one appended chunk. scr_exec_file installs it + * per stream, so programs without the exec callback forms link none of it + * (every program with an event loop links the capture path). */ +static void scr_exec_capture_check(ScrChildStream *s, size_t chunk_start) { + if (scr_exec_limit_chunk(s->capture_data, &s->capture_len, chunk_start, &s->capture_text_len, + s->capture_max)) + scr_child_stream_capture_over(s); +} + /* Node's properties on the callback form's error. Reached only through * the hook scr_exec_file installs, so programs without the callback form * link none of the property code (the settle path is shared by every @@ -5153,6 +5523,11 @@ static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { if (c->exec_props) c->exec_props(c, error); return error; } + if (c->exec_over != NULL) { + ScrError *error = c->exec_over; /* moves */ + c->exec_over = NULL; + return error; + } if (c->has_code && c->code == 0) return NULL; size_t cap = 16 + c->exec_display->len + 1 + stderr_value->len + 1; char *msg = malloc(cap); @@ -5171,6 +5546,12 @@ static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { static void scr_exec_file_fire(ScrChild *c) { if (c->exec_cb == NULL || c->exec_fn == NULL) return; + ScrChildStream *streams[2] = {c->out_stream, c->err_stream}; + for (int i = 0; i < 2; i++) { + if (streams[i] == NULL) continue; + streams[i]->capture_child = NULL; + streams[i]->capture_check = NULL; + } ScrStr *out = scr_child_stream_capture_value(c->out_stream); ScrStr *err = scr_child_stream_capture_value(c->err_stream); ScrError *error = scr_exec_file_error(c, err); @@ -5178,7 +5559,11 @@ static void scr_exec_file_fire(ScrChild *c) { } ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, - ScrExecFileFn fn, bool shell) { + ScrExecFileFn fn, bool shell, double max_buffer) { + if (!scr_exec_options_valid(0, max_buffer)) { + scr_closure_release(cb); + return NULL; + } ScrStr *empty = scr_str_new("", 0); ScrChild *c = scr_spawn_opts(cmd, args, 3, 3, 3, 0, 0, false, false, false, NULL, empty); @@ -5193,6 +5578,13 @@ ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, free(display); scr_child_stream_capture_begin(c->out_stream); scr_child_stream_capture_begin(c->err_stream); + ScrChildStream *streams[2] = {c->out_stream, c->err_stream}; + for (int i = 0; i < 2; i++) { + if (streams[i] == NULL) continue; + streams[i]->capture_max = max_buffer; + streams[i]->capture_child = c; + streams[i]->capture_check = &scr_exec_capture_check; + } return c; } diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index f59e02e744..95270973b0 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -46,12 +46,14 @@ ScrSpawnRes *scr_spawn_sync_stdio_str(ScrStr *cmd, ScrArr *args, double timeout_ * Result is the captured utf8 stdout (+1) — "" when nothing captures * (Node answers null there; discarded results cannot tell). A failure's * error carries Node's status, signal, output, pid, stdout and stderr; - * `buffers` (no encoding option) makes those outputs Buffers. All args - * borrowed. */ + * `buffers` (no encoding option) makes those outputs Buffers. Outputs + * together past max_buffer (0 = no limit) kill the child and throw Node's + * "spawnSync ENOBUFS"; a negative or NaN max_buffer throws + * ERR_OUT_OF_RANGE. All args borrowed. */ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, bool has_input, ScrStr *cwd, bool has_env, ScrArr *env_pairs, double timeout_ms, double stdout_mode, double stderr_mode, - bool buffers); + bool buffers, double max_buffer); /* The promisified-execFile/exec capture (cp.execCapture; shell as for * scr_exec_sync — the /bin/sh -c argv): the same core in the * async shape — both streams captured (no echo), Node's ASYNC error @@ -61,7 +63,13 @@ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, * signal, cmd, stdout and stderr properties. +1 ScrSpawnRes on success * (stdout + stderr; status unused). All args borrowed. */ ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd, - bool has_env, ScrArr *env_pairs, double timeout_ms); + bool has_env, ScrArr *env_pairs, double timeout_ms, + double max_buffer); +/* Node's validateTimeout then validateMaxBuffer for the promisified exec + * forms, which throw them synchronously before the promise exists: + * ERR_OUT_OF_RANGE for a timeout that is not a non-negative integer or a + * negative or NaN maxBuffer. */ +void scr_exec_validate(double timeout_ms, double max_buffer); ScrSpawnRes *scr_spawn_res_retain(ScrSpawnRes *r); void scr_spawn_res_release(ScrSpawnRes *r); void *scr_spawn_res_retain_v(void *p); @@ -161,9 +169,12 @@ void scr_child_on_spawn(ScrChild *child, ScrClosure *cb); /* moves zero-argument * all three stdio slots piped; stdout/stderr are captured internally and * 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. */ + * the command, as for scr_exec_sync. A stream past max_buffer is cut there, + * both captures end, the child gets SIGTERM and the callback receives + * Node's ERR_CHILD_PROCESS_STDIO_MAXBUFFER RangeError; a negative or NaN + * max_buffer throws ERR_OUT_OF_RANGE (NULL, the callback released). */ ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb /*moves*/, - ScrExecFileFn fn, bool shell); + ScrExecFileFn fn, bool shell, double max_buffer); ScrChild *scr_child_retain(ScrChild *c); void scr_child_release(ScrChild *c); void *scr_child_retain_v(void *p); diff --git a/tests/corpus/5637-child-max-buffer-sync.ts b/tests/corpus/5637-child-max-buffer-sync.ts new file mode 100644 index 0000000000..6260a50801 --- /dev/null +++ b/tests/corpus/5637-child-max-buffer-sync.ts @@ -0,0 +1,77 @@ +// The sync child_process forms enforce maxBuffer over both outputs +// together: past it the child is killed and Node's ENOBUFS spawnSync +// error is thrown, with the outputs cut at the chunk that passed it. A +// limit or timeout kill closes the pipes, so a grandchild holding one +// does not keep the call waiting. +import { execFileSync, execSync, spawnSync } from "node:child_process"; + +function show(label: string, e: unknown): void { + const err = e as { + message: string; + code?: string; + errno?: number; + syscall?: string; + status?: number | null; + signal?: string | null; + stdout?: string; + }; + console.log(label, err.message, err.code, err.errno, err.syscall, err.status, err.signal); + console.log(label, "stdout", typeof err.stdout, (err.stdout ?? "").length > 0); +} + +try { + execFileSync("/bin/sh", ["-c", "head -c 100000 /dev/zero | tr '\\0' a; sleep 5"], { + encoding: "utf8", + stdio: "pipe", + maxBuffer: 1000, + }); + console.log("no throw"); +} catch (e) { + show("execFileSync", e); +} +try { + execSync("head -c 4000 /dev/zero | tr '\\0' b; sleep 5", { encoding: "utf8", stdio: "pipe", maxBuffer: 10 }); +} catch (e) { + show("execSync", e); +} +// Under the limit, and 0 (no limit). +const small = execSync("echo small", { encoding: "utf8", stdio: "pipe", maxBuffer: 6 }); +console.log("small", JSON.stringify(small)); +const big = execSync("head -c 3000000 /dev/zero | tr '\\0' c", { + encoding: "utf8", + stdio: "pipe", + maxBuffer: 0, +}); +console.log("unlimited", big.length); +// The default is 1 MiB. +try { + execSync("head -c 2000000 /dev/zero | tr '\\0' d; sleep 5", { encoding: "utf8", stdio: "pipe" }); +} catch (e) { + show("default", e); +} +// A grandchild holding the pipe open does not keep the call waiting. +const started = Date.now(); +try { + execSync("head -c 5000 /dev/zero; sleep 5", { encoding: "utf8", stdio: "pipe", maxBuffer: 100 }); +} catch (e) { + show("grandchild", e); +} +console.log("prompt", Date.now() - started < 3000); +// The same after a timeout kill. +const timed = Date.now(); +try { + execSync("sleep 5; true", { encoding: "utf8", stdio: "pipe", timeout: 200 }); +} catch (e) { + show("timeout", e); +} +console.log("timeout prompt", Date.now() - timed < 3000); +// spawnSync applies the 1 MiB default. +const r = spawnSync("/bin/sh", ["-c", "head -c 2000000 /dev/zero; sleep 5"]); +console.log("spawnSync", r.status, r.signal, r.error?.message, (r.error as { code?: string } | undefined)?.code); +// A negative maxBuffer throws before anything runs. +try { + execSync("echo never", { encoding: "utf8", maxBuffer: -1 }); +} catch (e) { + const err = e as { name: string; message: string; code?: string }; + console.log(err.name, err.code, err.message); +} diff --git a/tests/corpus/5638-child-max-buffer-async.ts b/tests/corpus/5638-child-max-buffer-async.ts new file mode 100644 index 0000000000..88e1dbaeb0 --- /dev/null +++ b/tests/corpus/5638-child-max-buffer-async.ts @@ -0,0 +1,100 @@ +// exec and execFile enforce maxBuffer per stream: the stream that passes +// it keeps its first maxBuffer characters, the child is killed, and the +// callback gets Node's RangeError ERR_CHILD_PROCESS_STDIO_MAXBUFFER with +// cmd (and stdout/stderr when promisified). An invalid timeout or +// maxBuffer throws synchronously, the promisified forms included. +import { exec, execFile } from "node:child_process"; +import { inspect, promisify } from "node:util"; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +function show(label: string, e: unknown): void { + const err = e as { name: string; message: string; code?: string; cmd?: string; killed?: boolean; stdout?: string }; + console.log(label, err.name, err.message, err.code, err.cmd, err.killed, Object.keys(e as object).join(",")); +} + +async function main(): Promise { + try { + await execFileAsync("/bin/sh", ["-c", "printf 0123456789abcdef; sleep 5"], { encoding: "utf8", maxBuffer: 10 }); + } catch (e) { + const err = e as { stdout?: string; stderr?: string }; + show("execFileAsync", e); + console.log("cut", JSON.stringify(err.stdout), JSON.stringify(err.stderr)); + } + try { + await execAsync("printf 'abc' 1>&2; sleep 1; printf 'ok'", { encoding: "utf8", maxBuffer: 2 }); + } catch (e) { + const err = e as { stdout?: string; stderr?: string }; + show("execAsync", e); + console.log("cut", JSON.stringify(err.stdout), JSON.stringify(err.stderr)); + } + const fits = await execAsync("printf 'four'", { encoding: "utf8", maxBuffer: 4 }); + console.log("fits", JSON.stringify(fits.stdout)); + try { + await execAsync("printf x", { encoding: "utf8", maxBuffer: 0 }); + } catch (e) { + const err = e as { stdout?: string }; + show("zero", e); + console.log("zero", JSON.stringify(err.stdout)); + } + const empty = await execAsync("true", { encoding: "utf8", maxBuffer: 0 }); + console.log("empty", JSON.stringify(empty.stdout)); + // The cut counts UTF-16 code units of the chunk that passed the limit. + try { + await execAsync("printf 'éééééééééé'", { encoding: "utf8", maxBuffer: 5 }); + } catch (e) { + console.log("utf8", JSON.stringify((e as { stdout?: string }).stdout)); + } + try { + await execAsync("echo never", { encoding: "utf8", maxBuffer: -1 }); + } catch (e) { + show("negative", e); + } +} + +const started = Date.now(); +const limit = Number(process.argv.length > 99 ? "1" : "4"); +execFile("/bin/sh", ["-c", "printf 0123456789; sleep 5"], { encoding: "utf8", maxBuffer: limit }, (error, stdout, stderr) => { + show("execFile", error); + if (error) { + error.stack = ""; + console.log(inspect(error)); + } + console.log("callback", JSON.stringify(stdout), JSON.stringify(stderr), Date.now() - started < 3000); + exec("printf hello", { encoding: "utf8", maxBuffer: 3 }, (error2, stdout2) => { + show("exec", error2); + console.log("callback", JSON.stringify(stdout2)); + try { + execFile("/bin/echo", [], { encoding: "utf8", maxBuffer: 0 / 0 }, () => { + console.log("never"); + }); + } catch (e) { + show("nan", e); + } + void main().then(() => { + console.log("prompt", Date.now() - started < 6000); + checks(); + }); + }); +}); + +function checks(): void { + for (const timeout of [-1, 1.5, Infinity]) { + try { + void execAsync("echo never", { encoding: "utf8", timeout }); + } catch (e) { + show("timeout", e); + } + } + let n = 0; + const order = (v: number): number => { + console.log("arg", ++n, v); + return v; + }; + try { + void execFileAsync("/bin/echo", ["never"], { encoding: "utf8", timeout: order(-2), maxBuffer: order(-3) }); + } catch (e) { + show("order", e); + } +} diff --git a/tests/corpus/5641-child-max-buffer-fractional.ts b/tests/corpus/5641-child-max-buffer-fractional.ts new file mode 100644 index 0000000000..8def98b964 --- /dev/null +++ b/tests/corpus/5641-child-max-buffer-fractional.ts @@ -0,0 +1,40 @@ +// A fractional maxBuffer: exec compares the output's length with the +// limit as a number, then keeps `maxBuffer - previous total` UTF-16 code +// units truncated to an integer, as Node's slice does. A cut with less than +// one unit left keeps nothing more, and a cut with exactly one unit left +// inside an astral character keeps its high surrogate. +import { exec } from "node:child_process"; + +function run(command: string, maxBuffer: number): Promise { + return new Promise((resolve) => { + exec(command, { encoding: "utf8", maxBuffer }, (error, stdout) => { + const code = error ? (error as { code?: string }).code : "ok"; + resolve(`${maxBuffer} ${code} ${stdout.length} ${JSON.stringify(stdout.length > 16 ? stdout.slice(-4) : stdout)}`); + }); + }); +} + +async function main(): Promise { + for (const [text, max] of [ + ["abc", 2.5], + ["abc", 0.5], + ["abc", 2.999], + ["abc", 3.5], + ["a😀b", 1.5], + ["a😀b", 2.5], + ["a😀b", 3.5], + ["😀", 0.5], + ["😀", 1.5], + ["é😀", 2.5], + ] as [string, number][]) { + console.log(JSON.stringify(text), await run(`printf '%s' '${text}'`, max)); + } + // Cuts on the last character of outputs whose lengths are powers of two, + // so some cut lands on the end of the capture buffer. + for (let length = 1; length <= 1 << 17; length *= 2) { + console.log("x", length, await run(`head -c ${length} /dev/zero | tr '\\0' x`, length - 0.5)); + if (length > 1) console.log("y", length, await run(`head -c ${length} /dev/zero | tr '\\0' y`, length - 1.5)); + } +} + +void main(); diff --git a/tests/corpus/6490-emit-warning-self-replace.ts b/tests/corpus/6490-emit-warning-self-replace.ts new file mode 100644 index 0000000000..4428488d5d --- /dev/null +++ b/tests/corpus/6490-emit-warning-self-replace.ts @@ -0,0 +1,47 @@ +// A process.emitWarning replacement that assigns a new replacement while it +// runs keeps running with its own captured variables: the call keeps the +// replaced function alive until it returns. Covers process.emitWarning +// calls and the warnings Node's own modules emit (a util.deprecate +// wrapper's first call). +import { deprecate } from "node:util"; + +function install(label: string): void { + let calls = 0; + const seen: string[] = [label]; + process.emitWarning = ((warning: unknown) => { + process.emitWarning = ((next: unknown) => { + console.log("replacement", String(next)); + }) as typeof process.emitWarning; + calls++; + seen.push(String(warning)); + console.log("first", label, calls, seen.join(",")); + }) as typeof process.emitWarning; +} + +install("direct"); +process.emitWarning("a"); +process.emitWarning("b"); + +install("deprecate"); +const old = deprecate(() => "result", "old() is deprecated", "DEP_SCRIPTC_2"); +console.log("call", old()); +process.emitWarning("c"); + +function throwing(label: string): void { + let calls = 0; + process.emitWarning = (() => { + process.emitWarning = ((next: unknown) => { + console.log("after throw", String(next)); + }) as typeof process.emitWarning; + calls++; + throw new Error(`${label} ${calls}`); + }) as typeof process.emitWarning; +} + +throwing("thrown"); +try { + process.emitWarning("d"); +} catch (error) { + console.log("caught", (error as Error).message); +} +process.emitWarning("e"); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index 02b4349e8f..ce57b71d2e 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -31,8 +31,6 @@ const f = c.on; // Node's options or the optional-callback overload family. execFile("true"); 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 diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index f5f9f8f86c..896dede7fc 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -44,65 +44,56 @@ child-process.ts:33:24 - error SC2020: 'execFile option 'cwd'' is part of the st 32 | execFile("true"); 33 | execFile("true", [], { cwd: "/tmp" }, () => {}); | ^~~~~~~~~~~ - 34 | const bufferLimit = 1024; + 34 | const getEncoding = (): "utf8" => "utf8"; - hint: only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form + hint: only encoding: 'utf8' and maxBuffer are supported in the callback form -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 +child-process.ts:35:34 - error SC2020: 'execFile with a non-literal utf8 encoding' is part of the standard library types but has no scriptc lowering yet - 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() }, () => {}); + 34 | const getEncoding = (): "utf8" => "utf8"; + 35 | execFile("true", [], { encoding: getEncoding() }, () => {}); | ^~~~~~~~~~~~~ - 38 | // Fork targets are part of the compiled graph and therefore must resolve at + 36 | // 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 +child-process.ts:38: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 | // build time. The remaining channel is JSON-only and occupies stdio slot 3. + 38 | fork(process.argv[1]!); | ^~~~~~~~~~~~~~~~ - 41 | const staticWorker = new URL("./child-process.ts", import.meta.url); + 39 | 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:42:41 - error SC2020: 'fork with non-JSON serialization' is part of the standard library types but has no scriptc lowering yet +child-process.ts:40:41 - error SC2020: 'fork with non-JSON serialization' is part of the standard library types but has no scriptc lowering yet - 41 | const staticWorker = new URL("./child-process.ts", import.meta.url); - 42 | fork(staticWorker, [], { serialization: "advanced" }); + 39 | const staticWorker = new URL("./child-process.ts", import.meta.url); + 40 | fork(staticWorker, [], { serialization: "advanced" }); | ^~~~~~~~~~ - 43 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); + 41 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); hint: the static IPC channel supports Node's default serialization or serialization: "json" -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 +child-process.ts:41:64 - error SC2020: 'fork without a fourth-slot IPC channel' is part of the standard library types but has no scriptc lowering yet - 42 | fork(staticWorker, [], { serialization: "advanced" }); - 43 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); + 40 | fork(staticWorker, [], { serialization: "advanced" }); + 41 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); | ^~~~~~~~ - 44 | // Keep each fence on its own statement so diagnostics remain site-specific. + 42 | // Keep each fence on its own statement so diagnostics remain site-specific. hint: the fourth stdio entry must be "ipc" -child-process.ts:48:21 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet +child-process.ts:46:21 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet - 47 | const pipedOut = piped.stdout!; - 48 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); + 45 | const pipedOut = piped.stdout!; + 46 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 49 | pipedOut.setEncoding("utf8"); + 47 | pipedOut.setEncoding("utf8"); -child-process.ts:54:26 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet +child-process.ts:52:26 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet - 53 | pipedErr.setEncoding("utf8"); - 54 | piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); + 51 | pipedErr.setEncoding("utf8"); + 52 | piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 55 | \ No newline at end of file + 53 | \ No newline at end of file diff --git a/tests/harness/corpus-costs/5637-child-max-buffer-sync.ts.json b/tests/harness/corpus-costs/5637-child-max-buffer-sync.ts.json new file mode 100644 index 0000000000..9619c105e9 --- /dev/null +++ b/tests/harness/corpus-costs/5637-child-max-buffer-sync.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3200 } diff --git a/tests/harness/corpus-costs/5638-child-max-buffer-async.ts.json b/tests/harness/corpus-costs/5638-child-max-buffer-async.ts.json new file mode 100644 index 0000000000..6a888e3d57 --- /dev/null +++ b/tests/harness/corpus-costs/5638-child-max-buffer-async.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 4800 } diff --git a/tests/harness/corpus-costs/5641-child-max-buffer-fractional.ts.json b/tests/harness/corpus-costs/5641-child-max-buffer-fractional.ts.json new file mode 100644 index 0000000000..8fc202c7af --- /dev/null +++ b/tests/harness/corpus-costs/5641-child-max-buffer-fractional.ts.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 3200 } diff --git a/tests/harness/corpus-costs/6490-emit-warning-self-replace.ts.json b/tests/harness/corpus-costs/6490-emit-warning-self-replace.ts.json new file mode 100644 index 0000000000..83afe20ae2 --- /dev/null +++ b/tests/harness/corpus-costs/6490-emit-warning-self-replace.ts.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 3400 }