From 85690cf2f409bc9b17f0801c2fae8a7b860a9712 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 21:36:39 -0500 Subject: [PATCH 1/6] Order only timers in the promisified timers test 5628-promisify-timers.ts compared a 1 ms timer with an immediate armed at the same time. Their order depends on how much time has passed when the loop reaches its timers phase: once the delay has elapsed, the timer fires before the check phase. Node prints either order under load. The test now orders three timers armed together (1, 10 and 30 ms), which settle in deadline order. The node-types fixture does the same with two timers. --- tests/corpus/5628-promisify-timers.ts | 8 ++++++-- tests/fixtures/node-types/promisify-timers.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/corpus/5628-promisify-timers.ts b/tests/corpus/5628-promisify-timers.ts index 904ffc593..fd53f3814 100644 --- a/tests/corpus/5628-promisify-timers.ts +++ b/tests/corpus/5628-promisify-timers.ts @@ -20,11 +20,15 @@ async function main(): Promise { console.log("module timeout:", await sleepFromModule(1, 42)); console.log("module immediate:", await immediateFromModule(true)); + // Timers armed together settle in deadline order however late the loop + // reaches its timers phase. (An immediate against a short timer is not + // ordered: the timer fires first once its delay has passed when the loop + // reaches the timers phase, in Node too.) const order: string[] = []; const slow = sleep(30, "slow").then((v) => order.push(v)); const fast = sleep(1, "fast").then((v) => order.push(v)); - const now = immediate("now").then((v) => order.push(v)); - await Promise.all([slow, fast, now]); + const middle = sleep(10, "middle").then((v) => order.push(v)); + await Promise.all([slow, fast, middle]); console.log("order:", order.join(",")); const started = Date.now(); diff --git a/tests/fixtures/node-types/promisify-timers.ts b/tests/fixtures/node-types/promisify-timers.ts index 04ff14fd0..da2a8cfea 100644 --- a/tests/fixtures/node-types/promisify-timers.ts +++ b/tests/fixtures/node-types/promisify-timers.ts @@ -11,6 +11,6 @@ console.log(await sleepFromModule(1, true)); const order: string[] = []; await Promise.all([ sleep(20, "slow").then((v) => order.push(v)), - immediate("now").then((v) => order.push(v)), + sleep(1, "fast").then((v) => order.push(v)), ]); console.log(order.join(",")); From d7a556872666cd9c3d5003ae22c14411e7e56cf2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 22:05:36 -0500 Subject: [PATCH 2/6] Revert "Lower child_process.exec's callback forms" (#1161) --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 +-- .../generated/node-v24-backlog.json | 20 +++--- .../generated/node-v24-internal.json | 18 ++--- internal/compatibility/static-support.json | 1 - .../ambient/scriptc-node-fallback.d.ts | 8 +-- .../compiler/src/backend/llvm/lib-process.ts | 5 +- .../src/frontend/lowering/builtin-calls.ts | 4 +- .../lowering/builtins/child-process.ts | 72 ++++++------------- .../src/frontend/lowering/builtins/errors.ts | 25 ------- .../src/frontend/lowering/lower-exprs.ts | 5 -- .../compiler/src/frontend/lowering/lowerer.ts | 4 +- packages/compiler/src/frontend/type-mapper.ts | 17 ----- .../compiler/src/ir/builtin-signatures.ts | 5 +- packages/compiler/src/ir/validate.test.ts | 1 - .../tests/corpus/5627-exec-callback.ts.json | 6 -- .../node-types/child-exec-code-fences.ts.json | 6 -- .../fixtures/node-types/child-exec.ts.json | 6 -- packages/runtime/src/scr_child.c | 8 +-- packages/runtime/src/scr_child.h | 6 +- tests/corpus/5627-exec-callback.ts | 36 ---------- .../node-types/child-exec-code-fences.ts | 8 --- tests/fixtures/node-types/child-exec.ts | 9 --- tests/harness/project-config.test.ts | 34 --------- tests/harness/windows-differential.test.ts | 2 - 25 files changed, 59 insertions(+), 259 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5627-exec-callback.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec-code-fences.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec.ts.json delete mode 100644 tests/corpus/5627-exec-callback.ts delete mode 100644 tests/fixtures/node-types/child-exec-code-fences.ts delete mode 100644 tests/fixtures/node-types/child-exec.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 14b42ba7f..e78288558 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": "c51180a4672492a73f1d", + "artifactVersion": "fba8a4c99de7c5424f50", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index ec30a1c1b..8e2807d58 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": 17, "refused": 0, - "not-implemented": 21, + "not-implemented": 22, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -9312,9 +9312,9 @@ "inherited": true }, "static": { - "status": "partial", - "detail": "Implemented for the documented scriptc module-loader subset.", - "verification": "test-backed" + "status": "not-implemented", + "detail": "No static compiler lowering is registered for this API yet.", + "verification": "registry-gap" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 1db4326a5..13ead17e6 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3472, "tiers": { "static": { - "audit-partial": 1059, - "verify-gap": 2286, + "audit-partial": 1058, + "verify-gap": 2287, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -3941,16 +3941,12 @@ "priority": "high", "tiers": { "static": { - "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" - ] + "status": "not-implemented", + "action": "verify-gap", + "verification": "registry-gap", + "confidence": "low", + "source": "compiler-unmatched:child_process", + "tests": [] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 180e0be8d..bc9f9e32e 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": 17, "refused": 0, - "not-implemented": 21, + "not-implemented": 22, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -11663,10 +11663,7 @@ "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/5627-exec-callback.ts", - "tests/fixtures/node-types/child-exec.ts", - "tests/harness/project-config.test.ts" + "tests/corpus/3132-child-generic-stream-completion.ts" ] }, "anchorSource": "chapter" @@ -11735,13 +11732,8 @@ "inherited": true }, "static": { - "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" - ] + "status": "not-implemented", + "evidence": "compiler-unmatched:child_process" }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 4a46aec53..c5c99779a 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -306,7 +306,6 @@ { "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 49dbf948f..ff1fd81de 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2971,10 +2971,10 @@ declare module "child_process" { args?: string[] | null, options?: ExecSyncOptions, ): unknown; - /* The async shell form. 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. */ + /* 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. */ 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 8c3571c3d..05d3c2784 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -18,13 +18,12 @@ 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, i1)`); + host.declare(`declare ptr @scr_exec_file(ptr, ptr, ptr, ptr)`); 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})`, ); 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 9c2eeed45..8aec7420f 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -55,8 +55,8 @@ export function lowerBuiltinCall( loc: SrcLoc, namespaceMember?: ts.MemberName, ): IrExpr { - if (bi.module === "child_process" && (bi.member === "execFile" || bi.member === "exec")) { - return lowerer.lowerExecFileCall(expr, loc, bi.member === "exec"); + if (bi.module === "child_process" && bi.member === "execFile") { + return lowerer.lowerExecFileCall(expr, loc); } 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 ecec14fc5..8c06a28df 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process.ts @@ -725,57 +725,35 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr } /** `execFile(file[, args][, options], callback)` — the asynchronous callback - * 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, + * slice. The child starts immediately with stdin/stdout/stderr piped; the + * runtime captures both outputs and invokes the error-first callback after + * settlement. The callback may ignore a suffix of `(error, stdout, * stderr)`, but every declared parameter must have the Node shape. 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, - shell = false, -): IrExpr { - const what = shell ? "exec" : "execFile"; +export function lowerExecFileCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { if ( expr.arguments.some(ts.isSpreadElement) || expr.arguments.length < 2 || - expr.arguments.length > (shell ? 3 : 4) + expr.arguments.length > 4 ) { lowerer.noLowering( - `${what} with ${expr.arguments.length} arguments`, + `execFile with ${expr.arguments.length} arguments`, expr, - 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)", + "the supported callback forms are execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback)", ); } - const first = lowerer.lowerExprExpecting(expr.arguments[0]!, STRING); + const cmd = lowerer.lowerExprExpecting(expr.arguments[0]!, STRING); + const argsNode = expr.arguments.length >= 3 ? expr.arguments[1] : undefined; const callbackNode = expr.arguments[expr.arguments.length - 1]!; - // 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; + const argv = lowerChildArgsArg(lowerer, argsNode, loc); + if (expr.arguments.length === 4) { + const options = expr.arguments[2]!; if (!ts.isObjectLiteralExpression(options)) { lowerer.noLowering( - `${what} with a non-literal options argument`, + "execFile with a non-literal options argument", options, "pass encoding and maxBuffer in an inline object literal", ); @@ -796,7 +774,7 @@ export function lowerExecFileCall( const member = optionMember(property); if (!member) lowerer.noLowering( - `${what} with this options shape`, + "execFile with this options shape", property, "use plain encoding and maxBuffer properties without spreads or computed keys", ); @@ -806,7 +784,7 @@ export function lowerExecFileCall( (member.value.text !== "utf8" && member.value.text !== "utf-8") ) { lowerer.noLowering( - `${what} with a non-literal utf8 encoding`, + "execFile with a non-literal utf8 encoding", member.value, "pass the literal 'utf8' or 'utf-8' (other expressions might have side effects)", ); @@ -814,14 +792,14 @@ export function lowerExecFileCall( } else if (member.name === "maxBuffer") { if (!pureNumber(member.value)) { lowerer.noLowering( - `${what} with a non-literal maxBuffer`, + "execFile with a non-literal maxBuffer", member.value, "pass a numeric literal or literal arithmetic (the native capture grows without enforcing this limit)", ); } } else { lowerer.noLowering( - `${what} option '${member.name}'`, + `execFile option '${member.name}'`, property, "only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form", ); @@ -837,14 +815,14 @@ export function lowerExecFileCall( lowerer.unsupported( "SC1090", callbackNode, - `${what} callbacks take (error), (error, stdout), (error, stdout, stderr), or no parameters`, + "execFile callbacks take (error), (error, stdout), (error, stdout, stderr), or no parameters", ); } if (callback.type.ret.kind !== "void") { lowerer.unsupported( "SC1090", callbackNode, - `${what} callbacks returning a value (make the callback body a block, or return nothing)`, + "execFile callbacks returning a value (make the callback body a block, or return nothing)", ); } const errorParam = callback.type.params[0]; @@ -859,7 +837,7 @@ export function lowerExecFileCall( lowerer.unsupported( "SC1090", callbackNode, - `${what} callbacks whose first parameter is not 'Error | null' (got '${lowerer.fmt(errorParam)}')`, + `execFile callbacks whose first parameter is not 'Error | null' (got '${lowerer.fmt(errorParam)}')`, ); } } @@ -868,15 +846,9 @@ export function lowerExecFileCall( lowerer.unsupported( "SC1090", callbackNode, - `${what} callbacks whose ${i === 1 ? "stdout" : "stderr"} parameter is not 'string'`, + `execFile callbacks whose ${i === 1 ? "stdout" : "stderr"} parameter is not 'string'`, ); } } - return { - kind: "libCall", - fn: "cp.execFile", - args: [cmd, argv, callback, boolLit(shell, loc)], - type: CHILD_T, - loc, - }; + return { kind: "libCall", fn: "cp.execFile", args: [cmd, argv, callback], 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 2521ded2e..7ea7be56a 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -21,28 +21,6 @@ const ASSERTION_ERROR_PROPERTIES: ReadonlySet = new Set([ * declaration is not stdlib). Reads only: writes keep their fence (no * compiled program constructs an errno error). Null for non-error * receivers and non-stdlib members, so the chain keeps trying. */ -/** 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", - ); -} - export function lowerErrorCodeProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -142,9 +120,6 @@ 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 dcb69d67a..7e1524022 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -8,7 +8,6 @@ import { lowerWorkerMetadata } from "./builtins/workers.js"; import { lowerReadlineProperty } from "./builtins/readline.js"; import { lowerBlobInstanceof } from "./builtins/blob.js"; import { lowerKeyObjectInstanceof } from "./builtins/key-object.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"; @@ -11119,7 +11118,6 @@ 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) { @@ -12716,9 +12714,6 @@ 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 0fff3c86a..25c70e2f4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11485,8 +11485,8 @@ export class Lowerer { return lowerSpawnCall(this, expr, loc); } - lowerExecFileCall(expr: ts.CallExpression, loc: SrcLoc, shell = false): IrExpr { - return lowerExecFileCall(this, expr, loc, shell); + lowerExecFileCall(expr: ts.CallExpression, loc: SrcLoc): IrExpr { + return lowerExecFileCall(this, expr, loc); } 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 4b577fd43..320d4b237 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1341,23 +1341,6 @@ 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 43940e2d8..969af228b 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2222,9 +2222,8 @@ 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. */ + "cp.execFile": { argTypes: [STRING, arrayOf(STRING), null], 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 56d571cfe..463b9bc62 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -951,7 +951,6 @@ 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 deleted file mode 100644 index cbefe8acc..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5627-exec-callback.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 deleted file mode 100644 index 8f8a2a4b8..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec-code-fences.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 deleted file mode 100644 index 73db22aca..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-exec.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 3bce01fc7..715b69ab0 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -2265,14 +2265,14 @@ static void scr_exec_file_fire(ScrChild *c) { } ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, - ScrExecFileFn fn, bool shell) { + ScrExecFileFn fn) { 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, shell); + char *display = scr_exec_display(cmd, args, false); c->exec_display = scr_str_new(display, strlen(display)); free(display); scr_child_stream_capture_begin(c->out_stream); @@ -4905,14 +4905,14 @@ static void scr_exec_file_fire(ScrChild *c) { } ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, - ScrExecFileFn fn, bool shell) { + ScrExecFileFn fn) { 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, shell); + char *display = scr_exec_display(cmd, args, false); 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 1819b9c53..350995e31 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -155,11 +155,9 @@ 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. With shell - * (exec), args is ["-c", command] for /bin/sh and failure messages show - * the command, as for scr_exec_sync. */ + * the callback moves into the child registry until settlement. */ ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb /*moves*/, - ScrExecFileFn fn, bool shell); + ScrExecFileFn fn); 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 deleted file mode 100644 index eed79e738..000000000 --- a/tests/corpus/5627-exec-callback.ts +++ /dev/null @@ -1,36 +0,0 @@ -// 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 deleted file mode 100644 index 71f627df4..000000000 --- a/tests/fixtures/node-types/child-exec-code-fences.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 97e9c546d..000000000 --- a/tests/fixtures/node-types/child-exec.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/project-config.test.ts b/tests/harness/project-config.test.ts index d82875089..5a20739d2 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -426,40 +426,6 @@ 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 dc199d38f..7e6ed205d 100644 --- a/tests/harness/windows-differential.test.ts +++ b/tests/harness/windows-differential.test.ts @@ -149,8 +149,6 @@ 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. From 6d4843ea99392aa10f32688e859090d3d5848738 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 22:36:18 -0500 Subject: [PATCH 3/6] Dispose Disposable-typed values and expose SuppressedError's error and suppressed `using` and `await using` now accept object literals and other values typed Disposable, AsyncDisposable or unknown. The declaration reads the value's [Symbol.dispose] method ([Symbol.asyncDispose] first for `await using`) and throws Node's TypeErrors for values that are not objects and for missing or non-callable methods; null and undefined dispose nothing. Scope exit calls the method with the value as `this`, and `await using` awaits the result of an async method but not of the sync fallback. A class instance reached through such a value throws an SC2020 error, because its dynamic view has no methods. SuppressedError is its own error class: a failing disposal over a pending error throws one whose `error` is the disposal error and whose `suppressed` is the pending error, both non-enumerable as in Node. `new SuppressedError(error, suppressed, message?)` and `instanceof SuppressedError` compile, and `extends SuppressedError` is refused. --- .../language-features/resource-disposal.mdx | 2 +- .../compiler/src/backend/llvm/lib-shared.ts | 3 + packages/compiler/src/backend/may-throw.ts | 2 + .../compiler/src/coverage/surface-manifest.ts | 4 +- .../src/frontend/lowering/builtins/errors.ts | 8 +- .../src/frontend/lowering/lower-classes.ts | 22 ++++ .../src/frontend/lowering/lower-stmts.ts | 56 ++++++++-- packages/compiler/src/frontend/type-mapper.ts | 8 +- packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 13 +++ packages/compiler/src/ir/ir.ts | 4 + packages/compiler/src/ir/runtime-features.ts | 8 +- packages/compiler/surface-manifest.json | 4 +- .../5850-using-disposable-values.ts.json | 6 ++ ...5851-using-suppressed-error-values.ts.json | 6 ++ .../5852-using-disposable-values-js.js.json | 6 ++ packages/runtime/src/scr_dyn.h | 12 +++ packages/runtime/src/scr_error.c | 4 +- packages/runtime/src/scr_error.h | 11 +- packages/runtime/src/scr_exception.c | 24 +++-- packages/runtime/src/scr_exception.h | 7 +- packages/runtime/src/scr_json.c | 28 +++++ packages/runtime/src/scr_symbol.c | 101 ++++++++++++++++++ tests/corpus/5850-using-disposable-values.ts | 57 ++++++++++ .../5851-using-suppressed-error-values.ts | 48 +++++++++ .../corpus/5852-using-disposable-values-js.js | 29 +++++ 26 files changed, 447 insertions(+), 29 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5850-using-disposable-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5851-using-suppressed-error-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5852-using-disposable-values-js.js.json create mode 100644 tests/corpus/5850-using-disposable-values.ts create mode 100644 tests/corpus/5851-using-suppressed-error-values.ts create mode 100644 tests/corpus/5852-using-disposable-values-js.js diff --git a/docs/content/limitations/language-features/resource-disposal.mdx b/docs/content/limitations/language-features/resource-disposal.mdx index 1f654998b..5163d5322 100644 --- a/docs/content/limitations/language-features/resource-disposal.mdx +++ b/docs/content/limitations/language-features/resource-disposal.mdx @@ -1 +1 @@ -- **Resource disposal:** Top-level and switch-clause `using` declarations, and `DisposableStack` constructors are unsupported. Block- and function-scoped `using` and `await using` support compiled classes with zero-parameter disposal methods and supported FileHandle, timer, immediate, child-process, and readline handles. Array `for (using ... of ...)` loops also compile. Caught `SuppressedError` values expose `name` and `message`, but not `error` or `suppressed` payloads. +- **Resource disposal:** Top-level and switch-clause `using` declarations, and `DisposableStack` constructors are unsupported. Block- and function-scoped `using` and `await using` support compiled classes with zero-parameter disposal methods, object literals and other `Disposable`- or `AsyncDisposable`-typed values, and supported FileHandle, timer, immediate, child-process, and readline handles. A class instance reached through a `Disposable`- or `unknown`-typed value throws an SC2020 error when its declaration runs; declare the variable with the class type. Array `for (using ... of ...)` loops also compile. `SuppressedError` values expose `error` and `suppressed`; `extends SuppressedError` and casting `unknown` to `SuppressedError` are unsupported (narrow with `instanceof`). diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c4434833c..7eeb9379c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -135,6 +135,7 @@ export const LIB_FN_SYMS: Record = { "error.domCause": "scr_domex_cause", "error.domClone": "scr_domex_clone", "error.newAggregate": "scr_aggregate_new", + "error.newSuppressed": "scr_suppressed_new", "error.aggregateSetErrors": "scr_aggregate_set_errors", "error.aggregateErrors": "scr_aggregate_errors", "dyn.errInstanceof": "scr_dyn_err_instanceof", @@ -1284,6 +1285,8 @@ export const LIB_FN_SYMS: Record = { "dyn.packPushSpreadIter": "scr_dyn_pack_push_spread_iter", "dyn.reflectApply": "scr_dyn_reflect_apply", "dyn.reflectGet": "scr_dyn_reflect_get", + "dyn.usingMethod": "scr_dyn_using_method", + "dyn.usingDispose": "scr_dyn_using_dispose", "dyn.reflectSet": "scr_dyn_reflect_set", "dyn.reflectDefine": "scr_dyn_reflect_define", "dyn.abstractEq": "scr_dyn_abstract_eq", diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 1c65da36f..ac45129f0 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -628,6 +628,8 @@ const ERROR_KIND_NAMES: readonly string[] = [ "EvalError", "URIError", "AggregateError", + "AssertionError", + "SuppressedError", ]; /** A short description of a node that seeds the may-throw fixpoint. */ diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index 4a32f403b..45814cb8b 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -205,12 +205,12 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes [ "using-declarations", "block- and function-scoped using declarations", - "LIFO disposal on normal and abrupt scope exit through [Symbol.dispose]", + "LIFO disposal on normal and abrupt scope exit through [Symbol.dispose], of class instances, object literals and Disposable-typed values; a failing disposal raises a SuppressedError with error and suppressed", ], [ "await-using-declarations", "block- and function-scoped await using declarations", - "LIFO asynchronous disposal through [Symbol.asyncDispose], with synchronous fallback", + "LIFO asynchronous disposal through [Symbol.asyncDispose], with synchronous fallback, of class instances, object literals and AsyncDisposable-typed values", ], [ "for-using-of", diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 7ea7be56a..3bc04ce07 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -82,10 +82,12 @@ export function lowerErrorCodeProperty( }; } // %AssertionError's actual, expected, operator, generatedMessage and - // diff are properties of the error's dyn view, read at the declared type. + // diff, and %SuppressedError's error and suppressed, are properties of + // the error's dyn view, read at the declared type. if ( - recvT.className === "%AssertionError" && - ASSERTION_ERROR_PROPERTIES.has(expr.name.text) && + ((recvT.className === "%AssertionError" && ASSERTION_ERROR_PROPERTIES.has(expr.name.text)) || + (recvT.className === "%SuppressedError" && + (expr.name.text === "error" || expr.name.text === "suppressed"))) && lowerer.isStdlibMember(expr) ) { const receiver = lowerer.lowerExpr(expr.expression); diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index c83a071c3..11421e30f 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2027,6 +2027,13 @@ export function collectClassShapeInner( "extending AssertionError (its constructor takes an options object the subclass constructor would have to forward — extend Error, or construct AssertionError directly)", ); } + if (lowerer.builtinErrorInfoOf(symbol)?.def.name === "%SuppressedError") { + lowerer.unsupported( + "SC1090", + t, + "extending SuppressedError (extend Error and keep error and suppressed in fields)", + ); + } const named = (symbol && lowerer.classBySymbol.get(symbol)) ?? lowerer.builtinErrorInfoOf(symbol) ?? @@ -8497,6 +8504,21 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { loc, }; } + if (errInfo && errInfo.def.name === "%SuppressedError") { + const args = expr.arguments ?? []; + if (args.length > 3 || args.some(ts.isSpreadElement)) { + lowerer.noLowering(`new SuppressedError with this argument list`, expr); + } + return { + kind: "libCall", + fn: "error.newSuppressed", + args: [0, 1, 2].map((index) => + args[index] ? lowerer.lowerExprExpecting(args[index], DYN) : dynUndefinedExpr(loc), + ), + type: { kind: "object", className: "%SuppressedError" }, + loc, + }; + } if (errInfo && errInfo.def.name === "%AggregateError") { const args = expr.arguments ?? []; if (args.length > 3 || args.some(ts.isSpreadElement)) { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 850af3e65..289ab8fce 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -189,7 +189,7 @@ import { fenceNodeModuleMutation } from "./lower-node-module.js"; import { fencePerfEntryKeyWalk } from "./builtins/performance.js"; interface UsingRegistration { - acquire: IrStmt; + acquire: IrStmt[]; cleanup: IrStmt[]; loc: SrcLoc; } @@ -403,14 +403,51 @@ function cleanupCall( return null; } +/** The statements that dispose `value` at scope exit. `setup` receives + * the statements that run at the declaration, after the value is bound. */ function cleanupForValue( lowerer: Lowerer, node: ts.Identifier, value: IrExpr, awaited: boolean, + setup: IrStmt[], ): IrStmt[] { const loc = locOf(node); if (isUnitType(value.type)) return []; + if (value.type.kind === "dyn") { + // A dyn value (an object literal, a Disposable-typed result): the + // declaration reads and checks its dispose method, and scope exit + // calls it with the value as `this`. + const method = lowerer.declareHiddenLocal("%dispose", DYN); + setup.push({ + kind: "varDecl", + localId: method.id, + init: { + kind: "libCall", + fn: "dyn.usingMethod", + args: [value, { kind: "boolLit", value: awaited, type: BOOL, loc }], + type: DYN, + loc, + }, + loc, + }); + const call: IrExpr = { + kind: "libCall", + fn: "dyn.usingDispose", + args: [{ kind: "varRef", localId: method.id, type: DYN, loc }, value], + type: DYN, + loc, + }; + return [ + { + kind: "exprStmt", + expr: awaited + ? { kind: "libCall", fn: "async.awaitDyn", args: [call], type: DYN, loc } + : call, + loc, + }, + ]; + } if (value.type.kind === "union") { const def = lowerer.unions.get(value.type.unionId); if (!def) @@ -427,7 +464,9 @@ function cleanupForValue( type: arm, loc, }; - const body = cleanupForValue(lowerer, node, narrowed, awaited); + if (arm.kind === "dyn") + lowerer.unsupported("SC1090", node, "disposing a union with an 'unknown' arm"); + const body = cleanupForValue(lowerer, node, narrowed, awaited, setup); out.push({ kind: "if", cond: { @@ -513,8 +552,9 @@ function lowerUsingStatement(lowerer: Lowerer, stmt: ts.VariableStatement): Usin if (!binding) throw new InternalCompilerError("lowerer bug: using declaration has no runtime binding"); const value = varRef(binding.id, binding.type, locOf(decl.name)); - const cleanup = cleanupForValue(lowerer, decl.name, value, awaited); - registrations.push({ acquire, cleanup, loc: locOf(decl) }); + const setup: IrStmt[] = []; + const cleanup = cleanupForValue(lowerer, decl.name, value, awaited, setup); + registrations.push({ acquire: [acquire, ...setup], cleanup, loc: locOf(decl) }); } return registrations; } @@ -634,8 +674,8 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir const registrations = lowerUsingStatement(lowerer, stmt); const accounting: IrStmt[] = []; for (const registration of registrations) { - out.push(registration.acquire); - accounting.push(registration.acquire, ...registration.cleanup); + out.push(...registration.acquire); + accounting.push(...registration.acquire, ...registration.cleanup); if (registration.cleanup.length > 0) { usingMarkers.push({ start: out.length, @@ -8612,8 +8652,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { const body = lowerer.inCtl("loop", () => lowerer.lowerScopedBlock(stmt.statement), labels); if (!resourceLoop) return forValues(local.id, body); const resource = varRef(local.id, local.type, locOf(decl.name)); - const cleanup = cleanupForValue(lowerer, decl.name, resource, isAwaitUsing(list)); + const setup: IrStmt[] = []; + const cleanup = cleanupForValue(lowerer, decl.name, resource, isAwaitUsing(list), setup); return forValues(local.id, [ + ...setup, { kind: "tryCatch", tryBody: body, diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 320d4b237..7d667976c 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2272,10 +2272,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // The stdlib Console interface (the global console and node:console // Console instances) is a native handle carried by the checked-dynamic // representation and consumed by the console method lowerings. The - // stdlib Disposable (events.addAbortListener's result) is a checked - // object carrying its Symbol.dispose method. + // stdlib Disposable (events.addAbortListener's result) and + // AsyncDisposable are checked objects carrying their Symbol.dispose or + // Symbol.asyncDispose method. if ( - (psym?.name === "Console" || psym?.name === "Disposable") && + (psym?.name === "Console" || psym?.name === "Disposable" || psym?.name === "AsyncDisposable") && checker .declarationsOf(psym) .some((d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())) @@ -4847,6 +4848,7 @@ function mapErrorMetadataIntersection(type: ts.Type, ctx: TypeMapperCtx): IrType "TypeError", "URIError", "AggregateError", + "SuppressedError", ].includes(symbol.name) && ctx.checker.declarationsOf(symbol).some((decl) => ctx.isStdlibFile(decl.getSourceFile())) ); diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index f9f80bec2..99029472e 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -29,6 +29,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.construct", "dyn.reflectApply", "dyn.reflectGet", + "dyn.usingMethod", + "dyn.usingDispose", "dyn.reflectSet", "dyn.reflectDefine", "dyn.abstractEq", @@ -345,6 +347,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "error.newOptions", "error.ctorOptions", "error.newAggregate", + "error.newSuppressed", "error.toString", "error.stack", "error.cause", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 969af228b..179375bd3 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3025,6 +3025,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ }, "error.aggregateSetErrors": { argTypes: [null, DYN], result: VOID }, "error.aggregateErrors": { argTypes: [null], result: DYN }, + /** `new SuppressedError(error, suppressed, message?)` (scr_json.c): all + * three dyn and borrowed; the message conversion can throw. Returns an + * owned %SuppressedError. */ + "error.newSuppressed": { + argTypes: [DYN, DYN, DYN], + result: { kind: "object", className: "%SuppressedError" }, + }, /** `d instanceof TypeError` (and the other BUILTIN error classes) on a * checked-dynamic value (scr_json.c): the from_error cache holds the * dyn↔error identity edge, so the test resolves the runtime error and @@ -3114,6 +3121,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.packPushSpreadIter": { argTypes: [DYN, DYN], result: VOID }, "dyn.reflectApply": { argTypes: [DYN, DYN, DYN], result: DYN }, "dyn.reflectGet": { argTypes: [DYN, DYN, DYN], result: DYN }, + /** `using` / `await using` with a dyn value (scr_json.c): usingMethod + * runs the declaration's checks and answers what usingDispose calls at + * scope exit (undefined for a null or undefined value). The bool selects + * `await using`. Both throw Node's TypeErrors or the method's error. */ + "dyn.usingMethod": { argTypes: [DYN, BOOL], result: DYN }, + "dyn.usingDispose": { argTypes: [DYN, DYN], result: DYN }, "dyn.reflectSet": { argTypes: [DYN, DYN, DYN, DYN], result: BOOL }, "dyn.reflectDefine": { argTypes: [DYN, DYN, DYN], result: BOOL }, "dyn.abstractEq": { argTypes: [DYN, DYN], result: BOOL }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 10d4d1a14..227852e71 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1388,6 +1388,10 @@ export const RUNTIME_ERROR_CLASSES: ReadonlyMap< // constructor. The Error layout; actual, expected, operator, // generatedMessage and diff are properties of the error's dyn view. ["%AssertionError", { lib: "AssertionError", kind: 9, base: "%Error" }], + // SuppressedError — the error a `using` disposal raises over a pending + // error, and its own constructor. The Error layout; error and suppressed + // are non-enumerable properties of the error's dyn view. + ["%SuppressedError", { lib: "SuppressedError", kind: 10, base: "%Error" }], ]); /** The runtime-provided node:events EventEmitter class (ScrEmitter / diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 06abe2c0a..a73538ef1 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -348,7 +348,13 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run features.tls = true; } if (fn.startsWith("http2.") && !HTTP2_LEGACY_FNS.has(fn)) features.http2 = true; - if (fn.startsWith("sym.") || fn === "util.isDeepStrictEqual") features.symbol = true; + if ( + fn.startsWith("sym.") || + fn === "util.isDeepStrictEqual" || + fn === "dyn.usingMethod" || + fn === "dyn.usingDispose" + ) + features.symbol = true; if (fn === "process.hrtimeValue") features.bigint = true; if (fn.startsWith("bigint.")) features.bigint = true; if (fn.startsWith("sp.") || fn === "url.searchParams") features.searchParams = true; diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 33cc6acb6..5c9f2e2f6 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -5446,7 +5446,7 @@ "kind": "syntax", "name": "block- and function-scoped await using declarations", "status": "static", - "note": "LIFO asynchronous disposal through [Symbol.asyncDispose], with synchronous fallback" + "note": "LIFO asynchronous disposal through [Symbol.asyncDispose], with synchronous fallback, of class instances, object literals and AsyncDisposable-typed values" }, { "id": "syntax.bigint-literals", @@ -5621,7 +5621,7 @@ "kind": "syntax", "name": "block- and function-scoped using declarations", "status": "static", - "note": "LIFO disposal on normal and abrupt scope exit through [Symbol.dispose]" + "note": "LIFO disposal on normal and abrupt scope exit through [Symbol.dispose], of class instances, object literals and Disposable-typed values; a failing disposal raises a SuppressedError with error and suppressed" }, { "id": "syntax.with-statements", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5850-using-disposable-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5850-using-disposable-values.ts.json new file mode 100644 index 000000000..6c6e7d429 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5850-using-disposable-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5850-using-disposable-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5851-using-suppressed-error-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5851-using-suppressed-error-values.ts.json new file mode 100644 index 000000000..a020b22bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5851-using-suppressed-error-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5851-using-suppressed-error-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5852-using-disposable-values-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5852-using-disposable-values-js.js.json new file mode 100644 index 000000000..8c557d27e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5852-using-disposable-values-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5852-using-disposable-values-js.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b390ae3aa..2f2435013 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -603,6 +603,18 @@ ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */ ScrSym *scr_sym_well_known(ScrStr *name); ScrDyn *scr_dyn_symbol_get(const ScrDyn *value, const ScrStr *key); ScrDyn *scr_dyn_symbol_key_get(const ScrDyn *value, const ScrDyn *key, bool optional); +/* `using` and `await using` with a dyn value. scr_dyn_using_method runs the + * declaration's checks: null and undefined answer undefined (nothing to + * dispose); any other value that is not an object or function throws + * Node's TypeError, as does a missing or non-callable [Symbol.dispose]. + * With `async`, [Symbol.asyncDispose] is read first and [Symbol.dispose] + * is the fallback, and the answer records which one was found. + * scr_dyn_using_dispose calls that answer with `value` as `this` at scope + * exit and returns the call's result (+1; undefined for a sync fallback, + * whose result `await using` does not await). Both borrow their operands + * and return NULL with the exception pending. */ +ScrDyn *scr_dyn_using_method(const ScrDyn *value, bool async); +ScrDyn *scr_dyn_using_dispose(const ScrDyn *method, const ScrDyn *value); void scr_dyn_symbol_key_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_key_placeholder(ScrDyn *value, ScrDyn *key); bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key); diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 8af5edcd2..36115fae0 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -33,7 +33,8 @@ static const char *const scr_error_names[SCR_ERR_COUNT] = { /* DOMException's DEFAULT name is "Error" too (WebIDL: the name * parameter defaults to "Error"); the class identity lives in the * vtable, never the name. */ - "Error", "ReferenceError", "EvalError", "URIError", "AggregateError", "AssertionError"}; + "Error", "ReferenceError", "EvalError", "URIError", "AggregateError", "AssertionError", + "SuppressedError"}; void scr_error_trace(void *e, ScrTraceVisit visit, void *ctx) { /* name/message are strings — never collector-headered; nothing to visit. */ @@ -163,6 +164,7 @@ SCR_TL ScrVt scr_error_vts[SCR_ERR_COUNT] = { {7, 7, &scr_error_reld}, /* URIError */ {8, 8, &scr_aggregate_reld}, /* AggregateError */ {9, 9, &scr_error_reld}, /* AssertionError */ + {10, 10, &scr_error_reld}, /* SuppressedError */ }; ScrError *scr_error_retain(ScrError *e) { diff --git a/packages/runtime/src/scr_error.h b/packages/runtime/src/scr_error.h index fd75333f3..a3616f303 100644 --- a/packages/runtime/src/scr_error.h +++ b/packages/runtime/src/scr_error.h @@ -119,7 +119,8 @@ enum { SCR_ERR_URI = 7, SCR_ERR_AGGREGATE = 8, /* AggregateError — ScrAggregateError, the wider layout */ SCR_ERR_ASSERTION = 9, /* node:assert's AssertionError — the Error layout */ - SCR_ERR_COUNT = 10, + SCR_ERR_SUPPRESSED = 10, /* SuppressedError — the Error layout; error and suppressed are view properties */ + SCR_ERR_COUNT = 11, }; extern SCR_TL ScrVt scr_error_vts[SCR_ERR_COUNT]; /* indexed by SCR_ERR_*; main() stamps pre/post */ @@ -201,6 +202,14 @@ ScrError *scr_aggregate_new(const struct ScrDyn *message, const struct ScrDyn *o void scr_aggregate_set_errors(ScrError *e, struct ScrDyn *errors); /* both borrowed */ struct ScrDyn *scr_aggregate_errors(ScrError *e); /* +1 */ +/* new SuppressedError(error, suppressed, message) (scr_json.c): the message + * as in scr_error_new_options, then `error` and `suppressed` as + * non-enumerable properties of the error's dyn view. All arguments are + * borrowed (dyn undefined when absent). Returns +1, or NULL with an + * exception pending when the message conversion throws. */ +ScrError *scr_suppressed_new(const struct ScrDyn *error, const struct ScrDyn *suppressed, + const struct ScrDyn *message); + /* Switch runtime-side error allocation to collector-headered (called from * main() when the compiler's cycle fixpoint marks the hierarchy). */ void scr_error_set_traced(void); diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index b20b9fb5d..1cf4815ca 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -354,10 +354,17 @@ void scr_rethrow(const ScrCaught *c) { } } -/* Explicit resource management's error merge. The full JavaScript object - * exposes `.error` and `.suppressed`; the static catch surface currently - * exposes Error name/message, so retain Node's externally visible identity - * and ordering while consuming both snapshots exactly once. */ +/* Explicit resource management's error merge: a SuppressedError whose + * `error` is the disposal error and whose `suppressed` is the body error. + * The hook is process-wide: every thread links the same scr_json.c. */ +static void (*scr_exc_suppressed_hook)(void *error_object, const ScrCaught *error, + const ScrCaught *suppressed) = NULL; + +void scr_exc_install_suppressed_hook(void (*fn)(void *error_object, const ScrCaught *error, + const ScrCaught *suppressed)) { + scr_exc_suppressed_hook = fn; +} + void scr_exc_suppress(ScrCaught *suppressed) { if (suppressed->kind == SCR_EXC_GENRET) { /* A generator .return() is a return completion, not a thrown error. @@ -371,11 +378,14 @@ void scr_exc_suppress(ScrCaught *suppressed) { scr_caught_release(suppressed); return; } + static const char text[] = "An error was suppressed during disposal"; + ScrStr *message = scr_str_new(text, sizeof text - 1); + ScrError *e = scr_error_new(SCR_ERR_SUPPRESSED, message); + scr_str_release(message); + if (scr_exc_suppressed_hook) scr_exc_suppressed_hook(e, error, suppressed); scr_caught_release(error); scr_caught_release(suppressed); - scr_throw_error_named( - scr_str_new("SuppressedError", sizeof("SuppressedError") - 1), - scr_str_new("An error was suppressed during disposal", sizeof("An error was suppressed during disposal") - 1)); + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } bool scr_caught_instanceof(const ScrCaught *c, size_t pre, size_t post) { diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index fcb81246d..7710930af 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -165,8 +165,13 @@ ScrCaught *scr_caught_retain(ScrCaught *c); void scr_caught_release(ScrCaught *c); /* NULL-tolerant */ void scr_caught_trace_v(void *c, ScrTraceVisit visit, void *ctx); /* Replace the pending disposal error with a SuppressedError and consume the - * original body-error snapshot. The current exception cell must be pending. */ + * original body-error snapshot. The current exception cell must be pending. + * The SuppressedError's `error` is the disposal error and `suppressed` the + * body error, set by the hook scr_json.c installs when it is linked (every + * read of either property links it). */ void scr_exc_suppress(ScrCaught *suppressed); +void scr_exc_install_suppressed_hook(void (*fn)(void *error_object, const ScrCaught *error, + const ScrCaught *suppressed)); /* Borrows the box; re-raises the saved exception exactly (payload RETAINED * — the binding stays live until its scope exits). */ void scr_rethrow(const ScrCaught *c); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index e0472aa1a..aac519fd7 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -10888,6 +10888,34 @@ ScrError *scr_error_new_options(int kind, const ScrDyn *message, const ScrDyn *o return e; } +/* SuppressedError's `error` and `suppressed`: non-enumerable own + * properties of the error's dyn view. Both values move in. */ +static void scr_suppressed_attach(ScrError *e, ScrDyn *error, ScrDyn *suppressed) { + ScrDyn *view = scr_dyn_from_error(e); + scr_dyn_obj_set(view, "error", 5, error); + view->v.obj.entries[view->v.obj.len - 1].enumerable = false; + scr_dyn_obj_set(view, "suppressed", 10, suppressed); + view->v.obj.entries[view->v.obj.len - 1].enumerable = false; + scr_dyn_release(view); +} + +static void scr_suppressed_hook(void *error_object, const ScrCaught *error, const ScrCaught *suppressed) { + scr_suppressed_attach((ScrError *)error_object, scr_caught_to_dyn(error), scr_caught_to_dyn(suppressed)); +} + +/* Disposal errors become SuppressedErrors with both values once this unit + * is linked; reading either property links it. */ +__attribute__((constructor)) static void scr_suppressed_hook_install(void) { + scr_exc_install_suppressed_hook(&scr_suppressed_hook); +} + +ScrError *scr_suppressed_new(const ScrDyn *error, const ScrDyn *suppressed, const ScrDyn *message) { + ScrError *e = scr_error_new_options(SCR_ERR_SUPPRESSED, message, NULL); + if (!e) return NULL; + scr_suppressed_attach(e, scr_dyn_retain((ScrDyn *)error), scr_dyn_retain((ScrDyn *)suppressed)); + return e; +} + ScrDyn *scr_error_cause(ScrError *e) { size_t hit = scr_errdyn_find(e, true); if (hit != SIZE_MAX) { diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index b3d60d354..a29a28ad2 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -356,3 +356,104 @@ void scr_dyn_dirent_finish(ScrDyn *obj) { break; } } + +/* The well-known symbol `Symbol.` as a dyn key (+1). */ +static ScrDyn *scr_using_key(const char *name, size_t len) { + ScrStr *text = scr_str_new(name, len); + ScrSym *symbol = scr_sym_well_known(text); + scr_str_release(text); + ScrDyn *key = scr_dyn_new_symbol(symbol); + scr_sym_release(symbol); + return key; +} + +static bool scr_using_typeof_is(const ScrDyn *value, const char *name, size_t len) { + ScrStr *type = scr_dyn_typeof(value); + bool match = type->len == len && !memcmp(type->data, name, len); + scr_str_release(type); + return match; +} + +/* The [Symbol.] method of a `using` value: NULL for an absent member + * (undefined or null), the callable member (+1), or NULL with a pending + * TypeError when the member is not callable. */ +static ScrDyn *scr_using_get_method(const ScrDyn *value, const char *name, size_t len, bool *absent) { + ScrDyn *key = scr_using_key(name, len); + ScrDyn *method = scr_dyn_symbol_key_get(value, key, false); + scr_dyn_release(key); + *absent = false; + if (!method) return NULL; + if (method->kind == SCR_DYN_UNDEF || method->kind == SCR_DYN_NULL) { + scr_dyn_release(method); + *absent = true; + return NULL; + } + if (!scr_using_typeof_is(method, "function", 8)) { + scr_dyn_release(method); + ScrJsonBuf message; + scr_jb_init(&message); + scr_jb_puts(&message, "Symbol(Symbol."); + scr_jb_write(&message, name, len); + scr_jb_puts(&message, ") is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message)); + return NULL; + } + return method; +} + +ScrDyn *scr_dyn_using_method(const ScrDyn *value, bool async) { + if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF) return scr_dyn_retain(scr_dyn_undefined()); + if (!scr_using_typeof_is(value, "object", 6) && !scr_using_typeof_is(value, "function", 8)) { + static const char message[] = "An object is expected with `using` declarations"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + bool absent = false; + if (async) { + ScrDyn *method = scr_using_get_method(value, "asyncDispose", 12, &absent); + if (!method && !absent) return NULL; + if (method) { + ScrDyn *record = scr_dyn_new_arr(); + scr_dyn_arr_push(record, method); + scr_dyn_arr_push(record, scr_dyn_new_bool(false)); + return record; + } + } + ScrDyn *method = scr_using_get_method(value, "dispose", 7, &absent); + if (!method) { + if (absent && value->kind == SCR_DYN_TYPED_REF && value->v.typed_ref.type_key_len > 7 && + !memcmp(value->v.typed_ref.type_key, "object:", 7)) { + /* A class instance seen through an `unknown` or Disposable-typed + * value: its dyn view carries no methods. */ + static const char message[] = + "disposing a class instance through a Disposable- or unknown-typed value has no lowering " + "yet (declare the variable with the class type)"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } else if (absent) { + static const char message[] = "Symbol(Symbol.dispose) is not a function"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + return NULL; + } + if (!async) return method; + ScrDyn *record = scr_dyn_new_arr(); + scr_dyn_arr_push(record, method); + scr_dyn_arr_push(record, scr_dyn_new_bool(true)); + return record; +} + +ScrDyn *scr_dyn_using_dispose(const ScrDyn *method, const ScrDyn *value) { + if (method->kind == SCR_DYN_UNDEF) return scr_dyn_retain(scr_dyn_undefined()); + bool sync_fallback = false; + const ScrDyn *callee = method; + if (method->kind == SCR_DYN_ARR) { + callee = method->v.arr.items[0]; + sync_fallback = method->v.arr.items[1]->v.b; + } + scr_dyn_this_push_dyn(value); + ScrDyn *result = scr_dyn_call(callee, NULL, 0, "[Symbol.dispose]"); + scr_dyn_this_pop(); + if (!result || !sync_fallback) return result; + scr_dyn_release(result); + return scr_dyn_retain(scr_dyn_undefined()); +} diff --git a/tests/corpus/5850-using-disposable-values.ts b/tests/corpus/5850-using-disposable-values.ts new file mode 100644 index 000000000..8724f1f8e --- /dev/null +++ b/tests/corpus/5850-using-disposable-values.ts @@ -0,0 +1,57 @@ +// 'using' and 'await using' with Disposable- and AsyncDisposable-typed values: +// object literals and function results, the declaration's TypeErrors, LIFO +// disposal, and the sync fallback of 'await using'. Node.js prints the same. +function attempt(name: string, f: () => void): void { + try { + f(); + console.log(name, "ok"); + } catch (e) { + console.log(name, (e as Error).name, (e as Error).message); + } +} +const values: unknown[] = [1, "s", true, {}, { [Symbol.dispose]: 5 }, null, undefined]; +for (const value of values) { + attempt(String(typeof value), () => { + using x = value as Disposable; + console.log("body"); + }); +} +function make(label: string): Disposable { + return { [Symbol.dispose]() { console.log("dispose", label); } }; +} +attempt("order", () => { + using a = make("a"); + using b = make("b"); + console.log("body"); +}); +async function main(): Promise { + const asyncValues: unknown[] = [5, {}, { [Symbol.asyncDispose]: 3 }]; + for (const value of asyncValues) { + try { + await using x = value as AsyncDisposable; + console.log("abody"); + } catch (e) { + console.log("async", (e as Error).name, (e as Error).message); + } + } + { + await using y = { [Symbol.dispose]() { console.log("sync in await using"); return 7; } } as Disposable; + await using z = { async [Symbol.asyncDispose]() { await null; console.log("async disposed"); } } as AsyncDisposable; + console.log("block"); + } + console.log("after"); +} +main(); + +const literal = { [Symbol.dispose]() { console.log("literal disposed"); } }; +function scope(): void { + using a = literal; + using b = make("b2"); + using c = null; + console.log("scope body"); +} +scope(); +for (const label of ["p", "q"]) { + using r = make(label); + console.log("loop", label); +} diff --git a/tests/corpus/5851-using-suppressed-error-values.ts b/tests/corpus/5851-using-suppressed-error-values.ts new file mode 100644 index 000000000..c46c35558 --- /dev/null +++ b/tests/corpus/5851-using-suppressed-error-values.ts @@ -0,0 +1,48 @@ +// SuppressedError: the error a 'using' disposal raises over a pending error, +// with its error and suppressed values, nested disposals, and the constructor. +// Node.js prints the same. +class Bad implements Disposable { + label: string; + constructor(label: string) { + this.label = label; + } + [Symbol.dispose](): void { + throw new Error("dispose " + this.label); + } +} +try { + using x = new Bad("x"); + throw new Error("in body"); +} catch (e) { + const se = e as SuppressedError; + console.log(se.name, se.message, e instanceof SuppressedError, e instanceof Error); + console.log((se.error as Error).message, (se.suppressed as Error).message); + console.log(String(se)); +} +try { + using a = new Bad("a"); + using b = new Bad("b"); + throw new RangeError("body"); +} catch (e) { + if (e instanceof SuppressedError) { + const inner = e.suppressed; + if (inner instanceof SuppressedError) + console.log((e.error as Error).message, inner.name, (inner.error as Error).message, (inner.suppressed as Error).name); + } +} +try { + using a = new Bad("only"); +} catch (e) { + console.log(e instanceof SuppressedError, (e as Error).message); +} +const made = new SuppressedError(1, "two", "msg"); +console.log(made.error, made.suppressed, made.message, made.name, made instanceof SuppressedError); +const bare = new SuppressedError(new TypeError("t"), undefined); +console.log(JSON.stringify(bare.message), (bare.error as Error).name, bare.suppressed); +try { + using v = { [Symbol.dispose]() { throw 42; } } as Disposable; + throw "body"; +} catch (e) { + const se = e as SuppressedError; + console.log(se.error, se.suppressed); +} diff --git a/tests/corpus/5852-using-disposable-values-js.js b/tests/corpus/5852-using-disposable-values-js.js new file mode 100644 index 000000000..85a5c340f --- /dev/null +++ b/tests/corpus/5852-using-disposable-values-js.js @@ -0,0 +1,29 @@ +// 'using' with object literals and function results in JavaScript, and the +// SuppressedError properties. Node.js prints the same. +function res(label, fail) { + return { [Symbol.dispose]() { console.log("close", label); if (fail) throw new Error("dispose " + label); } }; +} +{ + using a = res("a"); + using b = res("b"); + console.log("body"); +} +try { + using a = res("a", true); + using b = res("b", true); + throw new Error("body"); +} catch (e) { + console.log(e.name, e.message, e.error.message, e.suppressed.name, e.suppressed.error.message, e.suppressed.suppressed.message); + console.log(e instanceof SuppressedError, e instanceof Error, Object.keys(e), String(e)); +} +const se = new SuppressedError(1, "two", "msg"); +console.log(se.error, se.suppressed, se.message, se.name); +async function main() { + { + await using y = { [Symbol.dispose]() { console.log("sync in await using"); } }; + await using z = { async [Symbol.asyncDispose]() { await null; console.log("async disposed"); } }; + console.log("block"); + } + console.log("after"); +} +main(); From c34dd7898b16e8eb31cd5c692fe9146af06d3048 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 23:09:58 -0500 Subject: [PATCH 4/6] Keep disposal snapshots out of programs that never read them The SuppressedError hook was installed by a load-time constructor in scr_json.c, which kept the checked-dynamic conversion of caught values in every program that linked that unit. scr_exc_suppress now records the disposal and body snapshots in a side table in scr_exception.c, the error's dyn view reads them when scr_json.c builds it, and the SuppressedError's teardown releases them. --- packages/runtime/src/scr_error.c | 30 +++++++++++++++-- packages/runtime/src/scr_exception.c | 50 +++++++++++++++++++++++----- packages/runtime/src/scr_exception.h | 13 +++++--- packages/runtime/src/scr_json.c | 20 +++++------ 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 36115fae0..57c5857c9 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -141,6 +141,28 @@ static void scr_aggregate_destroy(void *obj) { scr_error_destroy(obj); } +/* SuppressedError teardown: the ScrError fields plus the disposal + * snapshots scr_exc_suppress recorded for its `error` and `suppressed`. */ +static void scr_suppressed_gcfree(void *obj) { + scr_exc_suppressed_forget(obj); + scr_error_gcfree(obj); +} + +static void scr_suppressed_destroy(void *obj) { + scr_exc_suppressed_forget(obj); + scr_error_destroy(obj); +} + +static void scr_suppressed_reld(void *obj) { + ScrError *e = (ScrError *)obj; + if (--e->rc == 0) { + if (scr_error_traced) scr_cyc_dead(e); + scr_rc_destroy(obj, scr_suppressed_destroy); + } else if (scr_error_traced) { + scr_cyc_released(e); + } +} + static void scr_aggregate_reld(void *obj) { ScrAggregateError *a = (ScrAggregateError *)obj; if (--a->base.rc == 0) { @@ -164,7 +186,7 @@ SCR_TL ScrVt scr_error_vts[SCR_ERR_COUNT] = { {7, 7, &scr_error_reld}, /* URIError */ {8, 8, &scr_aggregate_reld}, /* AggregateError */ {9, 9, &scr_error_reld}, /* AssertionError */ - {10, 10, &scr_error_reld}, /* SuppressedError */ + {10, 10, &scr_suppressed_reld}, /* SuppressedError */ }; ScrError *scr_error_retain(ScrError *e) { @@ -210,11 +232,13 @@ static ScrError *scr_error_alloc(int kind) { static ScrCycType error_type = { &scr_error_trace, &scr_error_gcfree, SCR_CYC_KIND_ERROR }; static ScrCycType domex_type = { &scr_error_trace, &scr_domex_gcfree, SCR_CYC_KIND_DOMEXCEPTION }; static ScrCycType aggregate_type = { &scr_error_trace, &scr_aggregate_gcfree, SCR_CYC_KIND_ERROR }; + static ScrCycType suppressed_type = { &scr_error_trace, &scr_suppressed_gcfree, SCR_CYC_KIND_ERROR }; ScrError *e; if (scr_error_traced) { e = scr_cyc_alloc(size, kind == SCR_ERR_DOMEX ? &domex_type - : kind == SCR_ERR_AGGREGATE ? &aggregate_type - : &error_type); + : kind == SCR_ERR_AGGREGATE ? &aggregate_type + : kind == SCR_ERR_SUPPRESSED ? &suppressed_type + : &error_type); } else { e = calloc(1, size); if (!e) scr_error_oom(); diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index 1cf4815ca..fb152e078 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -356,13 +356,37 @@ void scr_rethrow(const ScrCaught *c) { /* Explicit resource management's error merge: a SuppressedError whose * `error` is the disposal error and whose `suppressed` is the body error. - * The hook is process-wide: every thread links the same scr_json.c. */ -static void (*scr_exc_suppressed_hook)(void *error_object, const ScrCaught *error, - const ScrCaught *suppressed) = NULL; + * The snapshots wait in this table for the error's dyn view, so programs + * that never read them link no checked-dynamic code. */ +typedef struct ScrSuppressedEntry { + const void *error_object; + ScrCaught *error; + ScrCaught *suppressed; +} ScrSuppressedEntry; +static SCR_TL ScrSuppressedEntry *scr_suppressed_entries = NULL; +static SCR_TL size_t scr_suppressed_len = 0; +static SCR_TL size_t scr_suppressed_cap = 0; -void scr_exc_install_suppressed_hook(void (*fn)(void *error_object, const ScrCaught *error, - const ScrCaught *suppressed)) { - scr_exc_suppressed_hook = fn; +bool scr_exc_suppressed_values(const void *error_object, const ScrCaught **error, + const ScrCaught **suppressed) { + for (size_t i = 0; i < scr_suppressed_len; i++) { + if (scr_suppressed_entries[i].error_object != error_object) continue; + *error = scr_suppressed_entries[i].error; + *suppressed = scr_suppressed_entries[i].suppressed; + return true; + } + return false; +} + +void scr_exc_suppressed_forget(const void *error_object) { + for (size_t i = 0; i < scr_suppressed_len; i++) { + if (scr_suppressed_entries[i].error_object != error_object) continue; + ScrSuppressedEntry entry = scr_suppressed_entries[i]; + scr_suppressed_entries[i] = scr_suppressed_entries[--scr_suppressed_len]; + scr_caught_release(entry.error); + scr_caught_release(entry.suppressed); + return; + } } void scr_exc_suppress(ScrCaught *suppressed) { @@ -382,9 +406,17 @@ void scr_exc_suppress(ScrCaught *suppressed) { ScrStr *message = scr_str_new(text, sizeof text - 1); ScrError *e = scr_error_new(SCR_ERR_SUPPRESSED, message); scr_str_release(message); - if (scr_exc_suppressed_hook) scr_exc_suppressed_hook(e, error, suppressed); - scr_caught_release(error); - scr_caught_release(suppressed); + if (scr_suppressed_len == scr_suppressed_cap) { + size_t cap = scr_suppressed_cap ? scr_suppressed_cap * 2 : 4; + ScrSuppressedEntry *entries = realloc(scr_suppressed_entries, cap * sizeof *entries); + if (!entries) { + fputs("scriptc: out of memory\n", stderr); + abort(); + } + scr_suppressed_entries = entries; + scr_suppressed_cap = cap; + } + scr_suppressed_entries[scr_suppressed_len++] = (ScrSuppressedEntry){e, error, suppressed}; scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index 7710930af..c9622ceb3 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -166,12 +166,15 @@ void scr_caught_release(ScrCaught *c); /* NULL-tolerant */ void scr_caught_trace_v(void *c, ScrTraceVisit visit, void *ctx); /* Replace the pending disposal error with a SuppressedError and consume the * original body-error snapshot. The current exception cell must be pending. - * The SuppressedError's `error` is the disposal error and `suppressed` the - * body error, set by the hook scr_json.c installs when it is linked (every - * read of either property links it). */ + * The SuppressedError keeps both snapshots in a side table until its + * teardown: scr_exc_suppressed_values answers them (borrowed) for the + * error's `error` (the disposal error) and `suppressed` (the body error) + * when scr_json.c builds its dyn view, and scr_exc_suppressed_forget + * releases them. */ void scr_exc_suppress(ScrCaught *suppressed); -void scr_exc_install_suppressed_hook(void (*fn)(void *error_object, const ScrCaught *error, - const ScrCaught *suppressed)); +bool scr_exc_suppressed_values(const void *error_object, const ScrCaught **error, + const ScrCaught **suppressed); +void scr_exc_suppressed_forget(const void *error_object); /* Borrows the box; re-raises the saved exception exactly (payload RETAINED * — the binding stays live until its scope exits). */ void scr_rethrow(const ScrCaught *c); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index aac519fd7..00e974a49 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5677,6 +5677,16 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e) { scr_dyn_obj_set(d, "cause", 5, scr_domex_cause((ScrError *)e)); } } + /* A disposal SuppressedError's non-enumerable `error` and `suppressed` + * (scr_exc_suppress); a constructed one sets them on its view. */ + const ScrCaught *suppressed_error = NULL, *suppressed_value = NULL; + if (e->vt == &scr_error_vts[SCR_ERR_SUPPRESSED] && + scr_exc_suppressed_values(e, &suppressed_error, &suppressed_value)) { + scr_dyn_obj_set(d, "error", 5, scr_caught_to_dyn(suppressed_error)); + d->v.obj.entries[d->v.obj.len - 1].enumerable = false; + scr_dyn_obj_set(d, "suppressed", 10, scr_caught_to_dyn(suppressed_value)); + d->v.obj.entries[d->v.obj.len - 1].enumerable = false; + } /* AggregateError's own non-enumerable `errors` list, shared with the * typed read so both spellings answer the same array. */ if (e->vt == &scr_error_vts[SCR_ERR_AGGREGATE]) { @@ -10899,16 +10909,6 @@ static void scr_suppressed_attach(ScrError *e, ScrDyn *error, ScrDyn *suppressed scr_dyn_release(view); } -static void scr_suppressed_hook(void *error_object, const ScrCaught *error, const ScrCaught *suppressed) { - scr_suppressed_attach((ScrError *)error_object, scr_caught_to_dyn(error), scr_caught_to_dyn(suppressed)); -} - -/* Disposal errors become SuppressedErrors with both values once this unit - * is linked; reading either property links it. */ -__attribute__((constructor)) static void scr_suppressed_hook_install(void) { - scr_exc_install_suppressed_hook(&scr_suppressed_hook); -} - ScrError *scr_suppressed_new(const ScrDyn *error, const ScrDyn *suppressed, const ScrDyn *message) { ScrError *e = scr_error_new_options(SCR_ERR_SUPPRESSED, message, NULL); if (!e) return NULL; From f7ad1f96a4d9ae1cb34eefe71d154d975165c2f3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 01:47:40 -0500 Subject: [PATCH 5/6] Lower Array.fromAsync over arrays The ESNext array lib is now part of the type world, and Array.fromAsync(source, mapFn?) compiles when the source is an array of values or of promises. Each call lowers to an async helper with the spec's loop: elements are awaited through the async-from-sync iterator, with the same microtask turns as a `for await` loop over an array, and a mapping function's result is awaited before it is stored. A throwing or rejecting mapping function and a rejected element close the iterator before the returned promise rejects, on the turn Node rejects it. Async generator sources, a thisArg, and arrays that mix values and promises are refused. --- .../language-features/async-iteration.mdx | 2 +- .../lowering/containers/array-from-async.ts | 338 ++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 2 + packages/compiler/src/frontend/program.ts | 9 +- .../corpus/5854-array-from-async.ts.json | 6 + .../corpus/5855-array-from-async-js.js.json | 6 + tests/corpus/5854-array-from-async.ts | 56 +++ tests/corpus/5855-array-from-async-js.js | 14 + 8 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/containers/array-from-async.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json create mode 100644 tests/corpus/5854-array-from-async.ts create mode 100644 tests/corpus/5855-array-from-async-js.js diff --git a/docs/content/limitations/language-features/async-iteration.mdx b/docs/content/limitations/language-features/async-iteration.mdx index d36981e2c..ee570f17d 100644 --- a/docs/content/limitations/language-features/async-iteration.mdx +++ b/docs/content/limitations/language-features/async-iteration.mdx @@ -1 +1 @@ -- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. +- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises with an optional mapping function; async generator sources, a `thisArg`, and arrays that mix values and promises are unsupported. diff --git a/packages/compiler/src/frontend/lowering/containers/array-from-async.ts b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts new file mode 100644 index 000000000..43e8b4959 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts @@ -0,0 +1,338 @@ +import { boolLit, numLit, varRef } from "../../../ir/build.js"; +import * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; +import { + BOOL, + CAUGHT, + F64, + VOID, + type IrExpr, + type IrParam, + type IrStmt, + type IrType, + type SrcLoc, + typeEquals, +} from "../../../ir/ir.js"; +import { locOf } from "../../program.js"; +import { arrayValueRead, arrayValueStore, arrayValueType } from "../array-values.js"; +import { newFnCtx } from "../function-context.js"; +import { lowerArrayCallback } from "./callback-arguments.js"; + +/** `Array.fromAsync(source, mapFn?)` over an array, as an async helper + * with the spec's loop: + * + * const out = []; let k = 0; + * for await (const v of source) out.push(mapFn ? await mapFn(v, k++) : v); + * return out; + * + * An array iterates through the async-from-sync iterator: each element is + * awaited (a promise element through its promise, any other value as one + * resolved promise), and the iterator result is awaited again, which the + * helper spells as the same microtask hops the `for await` array loop + * takes. A throwing or rejecting mapFn, and a rejected element, close the + * iterator before the returned promise rejects. Null when this is not an + * `Array.fromAsync` call. */ +export function lowerArrayFromAsyncCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (call.questionDotToken || access.questionDotToken) return null; + if (!lowerer.isStdlibGlobal(access.expression, "Array")) return null; + if (access.name.text !== "fromAsync") return null; + const loc = locOf(call); + const args = call.arguments; + if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `Array.fromAsync with ${args.length} arguments`, + call, + "pass the source and an optional mapping function", + ); + } + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (resultT?.kind !== "promise" || resultT.inner.kind !== "array") + lowerer.badType(call, lowerer.typeOf(call)); + const outT = resultT.inner; + const source = lowerer.lowerExpr(args[0]!); + if (source.type.kind !== "array") { + if (source.type.kind === "generator" && source.type.async) { + // An async generator's completion settles one microtask later here + // than in Node, which would reorder the returned promise. + lowerer.noLowering( + "Array.fromAsync over an async generator", + args[0]!, + "collect its values with for await", + ); + } + lowerer.noLowering( + `Array.fromAsync over a value of type '${lowerer.checker.typeToString(lowerer.typeOf(args[0]!))}'`, + args[0]!, + "an array is the supported source", + ); + } + const step = arraySourceStep(lowerer, args[0]!, source.type.elem); + let mapper: (IrExpr & { type: IrType & { kind: "func" } }) | null = null; + if (args[1]) { + const callback = lowerArrayCallback(lowerer, args[1], [step.valueT], outT); + if (callback.arity > 2) + lowerer.noLowering("an Array.fromAsync mapping function with three parameters", args[1]); + mapper = callback.fnArg; + const ret = mapper.type.ret; + if (ret.kind !== "promise" && !nonThenable(lowerer, ret)) { + lowerer.noLowering( + "an Array.fromAsync mapping function whose result may be a promise or thenable", + args[1], + "return a value or a Promise", + ); + } + } + const name = `%array.fromAsync.${lowerer.liftedFns.length}`; + const context = newFnCtx(false, null, null, outT); + context.isAsync = true; + lowerer.fnStack.push(context); + try { + const src = lowerer.declareHiddenLocal("source", source.type); + const params: IrParam[] = [{ localId: src.id, name: "source", type: source.type }]; + let mapperRef: (IrExpr & { type: IrType & { kind: "func" } }) | null = null; + if (mapper) { + const local = lowerer.declareHiddenLocal("mapFn", mapper.type); + params.push({ localId: local.id, name: "mapFn", type: mapper.type }); + mapperRef = { kind: "varRef", localId: local.id, type: mapper.type, loc }; + } + const out = lowerer.declareHiddenLocal("%fromAsyncOut", outT); + const k = lowerer.declareHiddenLocal("%fromAsyncIndex", F64); + k.mutable = true; + const kRef = varRef(k.id, F64, loc); + const append = (value: IrExpr): IrStmt[] => { + const stmts: IrStmt[] = []; + let item = value; + if (mapperRef) { + const mapped: IrExpr = { + kind: "callValue", + callee: mapperRef, + args: [value, kRef].slice(0, mapperRef.type.params.length), + type: mapperRef.type.ret, + loc, + }; + if (mapped.type.kind === "promise") { + item = { kind: "awaitExpr", value: mapped, type: mapped.type.inner, loc }; + } else { + // Await of a value that is not a thenable: one resolved promise. + const held = lowerer.declareHiddenLocal("%fromAsyncMapped", mapped.type); + stmts.push({ kind: "varDecl", localId: held.id, init: mapped, loc }, hop(loc)); + item = varRef(held.id, held.type, loc); + } + } + stmts.push( + arrayValueStore( + lowerer, + varRef(out.id, outT, loc), + kRef, + // arrayValueStore stores an undefined arm (a hole) as undefined. + typeEquals(item.type, outT.elem) || item.type.kind === "union" + ? item + : lowerer.coerceToExpected(item, outT.elem), + outT.elem, + loc, + ), + { + kind: "assign", + localId: k.id, + value: { kind: "bin", op: "+", left: kRef, right: numLit(1, loc), type: F64, loc }, + loc, + }, + ); + return stmts; + }; + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: out.id, + init: { kind: "arrayLit", elems: [], type: outT, loc }, + loc, + }, + { kind: "varDecl", localId: k.id, init: numLit(0, loc), loc }, + ...step.loop(varRef(src.id, source.type, loc), append, loc), + { kind: "return", value: varRef(out.id, outT, loc), loc }, + ]; + lowerer.liftedFns.push({ + name, + params, + returnType: outT, + locals: context.locals, + body, + async: true, + loc, + }); + } finally { + lowerer.fnStack.pop(); + } + return { + kind: "call", + callee: name, + args: mapper ? [source, mapper] : [source], + type: resultT, + loc, + }; +} + +function hop(loc: SrcLoc): IrStmt { + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "async.hop", args: [], type: VOID, loc }, + loc, + }; +} + +/** A value whose await takes one microtask without calling user code: + * primitives, arrays and records without a `then` field. */ +function nonThenable(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "union") { + const arms = lowerer.unions.get(type.unionId)?.arms; + return arms !== undefined && arms.every((arm) => nonThenable(lowerer, arm)); + } + if (type.kind === "record") { + const shape = lowerer.shapes.get(type.shapeId); + return shape !== undefined && !shape.indexValue && !shape.fields.some((f) => f.name === "then"); + } + return ( + type.kind === "f64" || + type.kind === "bigint" || + type.kind === "string" || + type.kind === "bool" || + type.kind === "symbol" || + type.kind === "array" || + type.kind === "undefinedT" || + type.kind === "nullT" + ); +} + +/** The `for await` array loop (lowerForOf's async-from-sync form): two + * hops per element that is not a promise, the element's own await plus + * one hop for a promise element, two hops before the final done result, + * and one hop to close the iterator when the body completes abruptly. */ +function arraySourceStep( + lowerer: Lowerer, + node: ts.Expression, + elem: IrType, +): { + /** The awaited value each element hands to the mapping function. */ + valueT: IrType; + loop(source: IrExpr, append: (value: IrExpr) => IrStmt[], loc: SrcLoc): IrStmt[]; +} { + const yieldedT = arrayValueType(lowerer, elem); + let promiseTag: number | null = null; + let valueT = yieldedT; + const promiseElem = elem.kind === "promise" ? elem : null; + if ( + promiseElem && + yieldedT.kind === "union" && + !["void", "union", "dyn", "undefinedT", "nullT"].includes(promiseElem.inner.kind) + ) { + promiseTag = lowerer.armTag(yieldedT.unionId, promiseElem); + valueT = lowerer.withUndefinedArm(promiseElem.inner); + } else if (!nonThenable(lowerer, elem)) { + lowerer.noLowering( + "Array.fromAsync over array values that may be promises or thenables", + node, + "use an array of values or an array of promises", + ); + } + return { + valueT, + loop(source, append, loc) { + const arr = lowerer.declareHiddenLocal("%fromAsyncArray", source.type); + const cursor = lowerer.declareHiddenLocal("%fromAsyncCursor", F64); + cursor.mutable = true; + const needsClose = lowerer.declareHiddenLocal("%fromAsyncNeedsClose", BOOL); + needsClose.mutable = true; + const value = lowerer.declareHiddenLocal("%fromAsyncValue", valueT); + value.mutable = true; + const arrRef = varRef(arr.id, source.type, loc); + const cursorRef = varRef(cursor.id, F64, loc); + const read = arrayValueRead(lowerer, arrRef, cursorRef, elem, loc); + const setClose = (on: boolean): IrStmt => ({ + kind: "assign", + localId: needsClose.id, + value: boolLit(on, loc), + loc, + }); + // A rejected element rejects the next result one reaction later, and + // Node closes the iterator before the returned promise rejects. + const awaitInto = (awaited: IrExpr): IrStmt => { + const error = lowerer.declareHiddenLocal("%fromAsyncNextError", CAUGHT); + return { + kind: "tryCatch", + tryBody: [{ kind: "assign", localId: value.id, value: awaited, loc }], + catchBody: [hop(loc), hop(loc), { kind: "rethrow", localId: error.id, loc }], + catchLocalId: error.id, + finallyBody: null, + loc, + }; + }; + const next: IrStmt[] = []; + if (promiseTag !== null) { + next.push( + awaitInto({ kind: "awaitUnionExpr", value: read, promiseTag, type: valueT, loc }), + hop(loc), + ); + } else { + next.push({ kind: "assign", localId: value.id, value: read, loc }, hop(loc), hop(loc)); + } + const length: IrExpr = { + kind: "arrIntrinsic", + method: "length", + receiver: arrRef, + args: [], + type: F64, + loc, + }; + const loop: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: cursor.id, init: numLit(0, loc), loc }, + cond: boolLit(true, loc), + update: { + kind: "assign", + localId: cursor.id, + value: { kind: "bin", op: "+", left: cursorRef, right: numLit(1, loc), type: F64, loc }, + loc, + }, + body: [ + setClose(false), + { + kind: "if", + cond: { kind: "bin", op: ">=", left: cursorRef, right: length, type: BOOL, loc }, + then: [hop(loc), hop(loc), { kind: "break", loc }], + else_: null, + loc, + }, + ...next, + setClose(true), + ...append(varRef(value.id, valueT, loc)), + ], + loc, + }; + return [ + { kind: "varDecl", localId: arr.id, init: source, loc }, + { kind: "varDecl", localId: needsClose.id, init: boolLit(false, loc), loc }, + { kind: "varDecl", localId: value.id, init: null, loc }, + { + kind: "tryCatch", + tryBody: [loop], + catchBody: null, + catchLocalId: null, + finallyBody: [ + { + kind: "if", + cond: varRef(needsClose.id, BOOL, loc), + then: [hop(loc)], + else_: null, + loc, + }, + ], + loc, + }, + ]; + }, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1ad34f32f..e0a8c79ba 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -68,6 +68,7 @@ import { lowerArrayFromCall, lowerArrayOfCall, } from "./containers/array-construction.js"; +import { lowerArrayFromAsyncCall } from "./containers/array-from-async.js"; import { lowerDynArrayFilterCall, lowerDynArrayFlatMapCall } from "./containers/array-callbacks.js"; import { lowerGroupByStaticCall } from "./containers/collection-grouping.js"; import { lowerIteratorHelperCall } from "./containers/iterator-helpers.js"; @@ -1392,6 +1393,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerObjectStaticCall(lowerer, expr, expr.expression) ?? lowerObjectFromEntriesCall(lowerer, expr, expr.expression) ?? lowerArrayFromCall(lowerer, expr, expr.expression) ?? + lowerArrayFromAsyncCall(lowerer, expr, expr.expression) ?? lowerArrayOfCall(lowerer, expr, expr.expression) ?? lowerer.lowerArrayMethodCall(expr, expr.expression) ?? // Tuple reads preserve the receiver through argument evaluation; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 2dca21ce9..3a4ffd747 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -138,10 +138,11 @@ const FORCED_OPTIONS: ts.Ts7CompilerOptions = { // classification is intentionally independent below; this option is only // the binder's scope decision. moduleDetection: ts.ModuleDetectionKind.Force, - // Explicit resource management is an ESNext library slice in TS 7 even - // though Node 24 ships the runtime protocol. Include that focused lib - // without widening the rest of the standard-library target past ES2025. - lib: ["lib.es2025.d.ts", "lib.esnext.disposable.d.ts"], + // Explicit resource management and Array.fromAsync are ESNext library + // slices in TS 7 even though Node 24 ships them. Include those focused + // libs without widening the rest of the standard-library target past + // ES2025. + lib: ["lib.es2025.d.ts", "lib.esnext.disposable.d.ts", "lib.esnext.array.d.ts"], types: [], allowImportingTsExtensions: true, allowJs: true, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json new file mode 100644 index 000000000..927060a98 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5854-array-from-async.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json new file mode 100644 index 000000000..d32f3625d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5855-array-from-async-js.js" + ], + "diags": [] +} diff --git a/tests/corpus/5854-array-from-async.ts b/tests/corpus/5854-array-from-async.ts new file mode 100644 index 000000000..a2ab6ab1e --- /dev/null +++ b/tests/corpus/5854-array-from-async.ts @@ -0,0 +1,56 @@ +// Array.fromAsync over arrays of values and of promises, with and without a +// mapping function, interleaved with other promise jobs: every result, +// rejection and iterator close lands on the microtask Node uses. Node.js prints +// the same. +const log: string[] = []; +async function ticker(n: number): Promise { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +function report(label: string, job: Promise): Promise { + return job.then( + (a) => log.push(label + " " + a.join(",")), + (e) => log.push(label + " caught " + (e as Error).message), + ); +} +async function main(): Promise { + const tick = ticker(24); + const jobs = [ + report("values", Array.fromAsync([1, 2, 3])), + report("promises", Array.fromAsync([Promise.resolve(4), Promise.resolve(5)])), + report("empty", Array.fromAsync([] as number[])), + report("map", Array.fromAsync([1, 2], (x: number, i: number) => x * 2 + i)), + report("async map", Array.fromAsync([1, 2], async (x: number) => x + 1)), + report("promise map", Array.fromAsync([3, 4], (x: number) => Promise.resolve(x * x))), + report("promises async map", Array.fromAsync([Promise.resolve(1), Promise.resolve(2)], async (x: number) => x * 5)), + report( + "map throws", + Array.fromAsync([1, 2], (x: number) => { + if (x === 2) throw new Error("map failed"); + return x; + }), + ), + report( + "async map rejects", + Array.fromAsync([1, 2, 3], async (x: number) => { + if (x === 2) throw new Error("async map failed"); + return x; + }), + ), + report("first rejected", Array.fromAsync([Promise.reject(new Error("first")), Promise.resolve(1)])), + report("second rejected", Array.fromAsync([Promise.resolve(1), Promise.reject(new Error("second"))])), + report( + "later", + Array.fromAsync([new Promise((resolve) => setTimeout(() => resolve(7), 1)), Promise.resolve(8)]), + ), + ]; + log.push("sync end"); + await Promise.all(jobs); + await tick; + console.log(log.join("\n")); + const strings = await Array.fromAsync(["a", "b"], async (s: string, i: number) => s.repeat(i + 1)); + console.log(strings.join("|"), strings.length); +} +main(); diff --git a/tests/corpus/5855-array-from-async-js.js b/tests/corpus/5855-array-from-async-js.js new file mode 100644 index 000000000..972d3d35f --- /dev/null +++ b/tests/corpus/5855-array-from-async-js.js @@ -0,0 +1,14 @@ +// Array.fromAsync in JavaScript over arrays of values and promises, with a +// mapping function, beside other promise jobs. Node.js prints the same. +const order = []; +const job = Array.fromAsync([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)], (x, i) => x * 10 + i); +Promise.resolve() + .then(() => order.push("a")) + .then(() => order.push("b")) + .then(() => order.push("c")) + .then(() => order.push("d")); +job.then((values) => { + order.push("values " + values.join(",")); + console.log(order.join(" ")); +}); +Array.fromAsync(["x", "y"]).then((values) => console.log(values.join("+"))); From 3e83b4a5c7d0ddcaa99a374c5cfc1ea1ebba294b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 01:51:57 -0500 Subject: [PATCH 6/6] Lower util.promisify(stream.finished) and util.promisify(stream.pipeline) Node's util.promisify returns the custom promisified forms of stream.finished and stream.pipeline, which are stream/promises' functions of the same name. A const binding of promisify over the node:stream named imports or namespace members now registers that stream/promises member as the projection, so calls through the binding lower as the stream/promises call and accept the stream arguments and options it accepts. The fallback declarations gain the matching util.promisify overloads. pipeline's overload comes first: its (...streams: unknown[]) => unknown type is assignable to every other target's parameter type. --- .../limitations/builtin-function-values.mdx | 2 +- .../generated/node-v24-backlog.json | 1 + .../generated/node-v24-internal.json | 2 + internal/compatibility/static-support.json | 2 +- .../ambient/scriptc-node-fallback.d.ts | 9 +++ .../lowering/builtins/callable-aliases.ts | 11 ++- .../src/frontend/lowering/surfaces.ts | 2 +- packages/compiler/surface-manifest.json | 2 +- .../corpus/5629-promisify-stream.ts.json | 6 ++ tests/corpus/5629-promisify-stream.ts | 76 +++++++++++++++++++ 10 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5629-promisify-stream.ts.json create mode 100644 tests/corpus/5629-promisify-stream.ts diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index 90ce9fddb..d5546f9e2 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -5,7 +5,7 @@ Some builtins can be stored and passed as functions: - `util.isDeepStrictEqual`, `styleText`, `stripVTControlCharacters`, `toUSVString`, `inherits`, `deprecate`, `callbackify`, `debuglog`, and `debug`. - `fs.existsSync`, `unlinkSync`, `chmodSync`, `chownSync`, `renameSync`, and `closeSync`, and `fs/promises.unlink`, `chmod`, and `rename`. -Other immutable aliases of table-backed Node builtins support direct calls, but cannot be passed elsewhere. `util.promisify` supports compile-time conversions of `child_process.execFile`, `child_process.exec`, `setTimeout` and `setImmediate` (to the `timers/promises` functions, as in Node), and the `fs` callback functions that have an `fs/promises` counterpart (`access`, `appendFile`, `chmod`, `chown`, `copyFile`, `lchown`, `link`, `lstat`, `lutimes`, `mkdir`, `mkdtemp`, `readFile`, `readdir`, `readlink`, `realpath`, `rename`, `rm`, `rmdir`, `stat`, `statfs`, `symlink`, `truncate`, `unlink`, `utimes`, and `writeFile`); calls through the converted function lower as the `fs/promises` call. +Other immutable aliases of table-backed Node builtins support direct calls, but cannot be passed elsewhere. `util.promisify` supports compile-time conversions of `child_process.execFile`, `child_process.exec`, `setTimeout` and `setImmediate` (to the `timers/promises` functions, as in Node), `stream.finished` and `stream.pipeline` (to the `stream/promises` functions, as in Node), and the `fs` callback functions that have an `fs/promises` counterpart (`access`, `appendFile`, `chmod`, `chown`, `copyFile`, `lchown`, `link`, `lstat`, `lutimes`, `mkdir`, `mkdtemp`, `readFile`, `readdir`, `readlink`, `realpath`, `rename`, `rm`, `rmdir`, `stat`, `statfs`, `symlink`, `truncate`, `unlink`, `utimes`, and `writeFile`); calls through the converted function lower as the `fs/promises` call. `util.styleText` supports standard formats and aliases, `#RGB` and `#RRGGBB` hex colors, arrays of formats, nested ANSI resets, and explicit color enabling with `{ validateStream: false }`. Native calls accept standard process streams; custom native streams and mutations to `util.inspect.colors` remain unsupported. diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 13ead17e6..19ec1e964 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -76136,6 +76136,7 @@ "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", + "tests/corpus/5629-promisify-stream.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 bc9f9e32e..d9903c815 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -100566,6 +100566,7 @@ "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/corpus/5628-promisify-timers.ts", + "tests/corpus/5629-promisify-stream.ts", "tests/corpus/node24-deep-equality-properties.js", "tests/corpus/node24-deep-equality.cjs", "tests/corpus/node24-deep-equality.ts", @@ -102223,6 +102224,7 @@ "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", + "tests/corpus/5629-promisify-stream.ts", "tests/harness/project-config.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index c5c99779a..33f8b3ee7 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -341,7 +341,7 @@ { "chapter": "test", "parent": "Class: `MockFunctionContext`", "symbols": ["calls", "ctx.callCount", "ctx.mockImplementation", "ctx.mockImplementationOnce", "ctx.resetCalls", "ctx.restore"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.before", "context.after", "context.beforeEach", "context.afterEach", "context.plan", "context.fullName"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["describe.skip", "describe.todo", "describe.only", "suite.skip", "suite.todo", "suite.only", "it.skip", "it.todo", "it.only"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts"] }, - { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/harness/project-config.test.ts"] }, + { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/harness/project-config.test.ts"] }, { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/corpus/2707-type-import-link-static-default-reexport/main.ts", "tests/harness/errors.test.ts"] }, { "chapter": "zlib", "signatures": ["Class: `zlib.Deflate`", "Class: `zlib.Inflate`", "Class: `zlib.Gzip`", "Class: `zlib.Gunzip`", "Class: `zlib.DeflateRaw`", "Class: `zlib.InflateRaw`", "Class: `zlib.Unzip`", "Class: `zlib.ZlibBase`", "`zlib.close([callback])`", "`zlib.flush([kind, ]callback)`", "`zlib.params(level, strategy, callback)`", "`zlib.reset()`", "`bytesWritten` Type: {number}", "Class: `Options`"], "status": "partial", "evidence": ["tests/corpus/5370-zlib-stream-roundtrip.ts", "tests/corpus/5371-zlib-stream-chunking.cjs", "tests/corpus/5372-zlib-stream-errors.cjs", "tests/corpus/5373-zlib-stream-options.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts", "tests/corpus/5375-zlib-stream-promises.ts", "tests/corpus/5377-zlib-stream-backpressure.cjs"] }, { "chapter": "zlib", "signatures": ["`zlib.constants`"], "status": "partial", "evidence": ["tests/corpus/5376-zlib-constants.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts"] } diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index ff1fd81de..eb6aaa2ba 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3062,6 +3062,12 @@ declare module "util" { export type TextDecoder = globalThis.TextDecoder; export var TextEncoder: typeof globalThis.TextEncoder; export var TextDecoder: typeof globalThis.TextDecoder; + /* stream.pipeline's (...streams: unknown[]) => unknown is assignable to + * every other target's parameter type, so its overload comes first; no + * other target is assignable to it. */ + export function promisify( + fn: typeof import("node:stream").pipeline, + ): typeof import("node:stream/promises").pipeline; export function promisify( fn: typeof import("node:fs").readFile, ): (path: string, encoding: "utf8" | "utf-8") => Promise; @@ -3098,6 +3104,9 @@ declare module "util" { windowsHide?: boolean; }, ) => Promise<{ stdout: string; stderr: string }>; + export function promisify( + fn: typeof import("node:stream").finished, + ): typeof import("node:stream/promises").finished; /* Declared surface without full lowering (the type-level tolerance * story: guarded/diagnostic-path code must TYPECHECK; a reached use * without a lowering fences at its site). inspect's options carry the diff --git a/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts b/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts index eb33b0ba4..c54aa6884 100644 --- a/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts +++ b/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts @@ -147,12 +147,19 @@ export function registerPromisifiedBuiltinDecl( module: "timers/promises", member: target.member, } - : null; + : target?.module === "stream" && + (target.member === "finished" || target.member === "pipeline") + ? { + kind: "promisified-builtin" as const, + module: "stream/promises", + member: target.member, + } + : null; if (!projection) { lowerer.noLowering( "util.promisify of this target", argNode ?? e, - "child_process.exec, child_process.execFile, setTimeout, setImmediate and the fs callback functions with an fs/promises counterpart are the supported targets", + "child_process.exec, child_process.execFile, setTimeout, setImmediate, stream.finished, stream.pipeline and the fs callback functions with an fs/promises counterpart are the supported targets", ); } const symbol = lowerer.checker.getSymbolAtLocation(nameNode); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6f54713f1..859cd7305 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2809,7 +2809,7 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record< }, util: { promisify: - "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate and the fs callback functions with an fs/promises counterpart; call the bound function directly", + "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate, stream.finished, stream.pipeline and the fs callback functions with an fs/promises counterpart; call the bound function directly", }, "stream/consumers": { blob: diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 5c9f2e2f6..87f212aee 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -2426,7 +2426,7 @@ "name": "util.promisify", "status": "unsupported", "code": "SC2020", - "note": "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate and the fs callback functions with an fs/promises counterpart; call the bound function directly" + "note": "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate, stream.finished, stream.pipeline and the fs callback functions with an fs/promises counterpart; call the bound function directly" }, { "id": "node-builtin.util.stripVTControlCharacters", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5629-promisify-stream.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5629-promisify-stream.ts.json new file mode 100644 index 000000000..5cd452498 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5629-promisify-stream.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5629-promisify-stream.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5629-promisify-stream.ts b/tests/corpus/5629-promisify-stream.ts new file mode 100644 index 000000000..3d8e02dac --- /dev/null +++ b/tests/corpus/5629-promisify-stream.ts @@ -0,0 +1,76 @@ +// util.promisify(stream.finished) and util.promisify(stream.pipeline) are +// Node's custom promisified forms, stream/promises' finished and pipeline: +// fulfilled when the stream (or the last stage) closes cleanly, rejected +// with the stream's error or ERR_STREAM_PREMATURE_CLOSE. The named imports +// and the namespace members are both recognized. +import * as stream from "node:stream"; +import { PassThrough, Readable, Transform, Writable, finished } from "node:stream"; +import { promisify } from "node:util"; + +const finishedAsync = promisify(finished); +const pipelineAsync = promisify(stream.pipeline); + +function source(items: string[]): Readable { + let i = 0; + return new Readable({ + read() { + this.push(i < items.length ? items[i++]! : null); + }, + }); +} + +async function main(): Promise { + const clean = source(["a", "b"]); + const cleanDone = finishedAsync(clean); + clean.resume(); + await cleanDone; + console.log("finished: clean"); + + const torn = new Readable({ read() {} }); + const tornDone = finishedAsync(torn); + torn.destroy(new Error("torn")); + try { + await tornDone; + console.log("SHOULD NOT PRINT"); + } catch (e) { + if (e instanceof Error) console.log("finished: rejected", e.message); + } + + const early = new Readable({ read() {} }); + const earlyDone = finishedAsync(early); + early.destroy(); + try { + await earlyDone; + console.log("SHOULD NOT PRINT"); + } catch (e) { + if (e instanceof Error) console.log("finished: premature", (e as NodeJS.ErrnoException).code); + } + + const out: string[] = []; + const sink = new Writable({ + write(chunk: Buffer, _enc: string, cb: () => void) { + out.push(chunk.toString()); + cb(); + }, + }); + await pipelineAsync(source(["x", "y", "z"]), new PassThrough(), sink); + console.log("pipeline:", out.join(",")); + + const failing = new Transform({ + transform(_chunk: Buffer, _enc: string, cb: (err?: Error | null) => void) { + cb(new Error("boom")); + }, + }); + const drain = new Writable({ + write(_c: Buffer, _e: string, cb: () => void) { + cb(); + }, + }); + try { + await pipelineAsync(source(["q"]), failing, drain); + console.log("SHOULD NOT PRINT"); + } catch (e) { + if (e instanceof Error) console.log("pipeline: rejected", e.message, drain.destroyed); + } +} +main();