From 5d7234a61c9067aeb9180f99594ecc4ceb9fff7a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:12:27 -0500 Subject: [PATCH 01/12] Concatenate untyped Buffer lists as Node does Buffer.concat lowered only a statically typed Buffer[]/Uint8Array[] list or an array literal. Any other list, such as JavaScript's `const chunks = []`, reached an SC2020 fence at run time. Such lists now go to the runtime, which checks them as Node does (an Array whose elements are Uint8Arrays, ERR_INVALID_ARG_TYPE with Node's messages otherwise) before concatenating; the length form is covered as well. --- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/containers/bytes.ts | 22 +++++++-- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 2 + .../corpus/5973-buffer-concat-untyped.js.json | 6 +++ packages/runtime/src/scr_bytes.h | 4 ++ packages/runtime/src/scr_bytes_io.c | 46 +++++++++++++++++++ tests/corpus/5973-buffer-concat-untyped.js | 20 ++++++++ tests/harness/corpus-costs.json | 2 + 9 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json create mode 100644 tests/corpus/5973-buffer-concat-untyped.js diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 6516756e9..343393c2a 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -404,6 +404,8 @@ export const LIB_FN_SYMS: Record = { "buffer.fromDyn": "scr_buffer_from_dyn", "buffer.concat": "scr_buffer_concat", "buffer.concatLen": "scr_buffer_concat_len", + "buffer.concatDyn": "scr_buffer_concat_dyn", + "buffer.concatLenDyn": "scr_buffer_concat_len_dyn", "buffer.alloc": "scr_buffer_alloc", "buffer.allocUnsafe": "scr_buffer_alloc_unsafe", "buffer.allocUnsafeSlow": "scr_buffer_alloc_unsafe_slow", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 7a19927de..c41bf5163 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -1511,11 +1511,23 @@ function lowerBufferStaticValue( } else { list = lowerer.lowerExpr(argNode); if (!typeEquals(list.type, arrayOf(BYTES_U8))) { - lowerer.noLowering( - `Buffer.concat of '${lowerer.fmt(list.type)}' values`, - argNode, - "one Uint8Array[]/Buffer[] value (or literal) is the lowered list shape", - ); + // Any other list (JavaScript's untyped `const chunks = []`, or a + // union of arrays): the runtime checks it as Node does (an Array of + // Uint8Arrays, the ERR_INVALID_ARG_TYPE errors otherwise) before + // concatenating. + const value: IrExpr = + list.type.kind === "dyn" ? list : { kind: "dynFrom", value: list, type: DYN, loc }; + if (args.length === 2) { + const total = lowerer.lowerExprExpecting(args[1]!, F64); + return { + kind: "libCall", + fn: "buffer.concatLenDyn", + args: [value, total], + type: BYTES_U8, + loc, + }; + } + return { kind: "libCall", fn: "buffer.concatDyn", args: [value], type: BYTES_U8, loc }; } } if (args.length === 2) { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 96bd381f7..a872f3f42 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -670,6 +670,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "crypto.scryptCb", "buffer.concat", "buffer.concatLen", + "buffer.concatDyn", + "buffer.concatLenDyn", "buffer.fromDyn", "buffer.fromStrChecked", "buffer.alloc", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 6c09eeb01..9eb0948e0 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2575,6 +2575,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * opaque reference inputs retain explicit runtime refusals. */ "buffer.fromDyn": { argTypes: [DYN, STRING], result: BYTES_U8 }, "buffer.concat": { argTypes: [arrayOf(BYTES_U8)], result: BYTES_U8 }, + "buffer.concatDyn": { argTypes: [DYN], result: BYTES_U8 }, /** The pooled Buffer factories (Node's allocate/fromStringFast/ * fromArrayLike): allocUnsafe, Buffer.from of strings, Uint8Arrays, * number[] and checked values, and concat return views into the @@ -2601,6 +2602,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * zero-padded to the total. THROWS Node's 'length' RangeError on a * negative/non-integer total (may-throw seed). */ "buffer.concatLen": { argTypes: [arrayOf(BYTES_U8), F64], result: BYTES_U8 }, + "buffer.concatLenDyn": { argTypes: [DYN, F64], result: BYTES_U8 }, // The checked-dynamic compare/equals validators (Node's argument // ladders over dyn-boxed invalid-input probes). /** The checked-dynamic compare/equals validators (scr_bytes_io.c) — diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json new file mode 100644 index 000000000..3fffd75c2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5973-buffer-concat-untyped.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4309154c9..993921043 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -389,6 +389,10 @@ ScrBytes *scr_buffer_from_arr(const ScrArr *arr); /* number[] */ ScrBytes *scr_buffer_from_dyn_pooled(const ScrDyn *value, const ScrStr *encoding); ScrBytes *scr_buffer_concat(const ScrArr *list); ScrBytes *scr_buffer_concat_len(const ScrArr *list, double total); +/* The untyped-list forms: Node's ERR_INVALID_ARG_TYPE checks on the list + * and each element, then the typed concat. NULL with the exception pending. */ +ScrBytes *scr_buffer_concat_dyn(const struct ScrDyn *list); +ScrBytes *scr_buffer_concat_len_dyn(const struct ScrDyn *list, double total); /* Drops the pool's slab reference and restores Buffer.poolSize (process * exit audits and library session resets). */ void scr_buffer_pool_reset(void); diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 6fe829eeb..fc7b3bbb8 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1249,6 +1249,52 @@ static ScrBytes *scr_buffer_from_array_like(const ScrDyn *value, double length) return out; } +/* Buffer.concat over an untyped list: Node's checks (an Array whose every + * element is a Uint8Array, ERR_INVALID_ARG_TYPE otherwise), then the typed + * concat over the elements. total < 0 selects the one-argument form. NULL + * with the exception pending. */ +static ScrBytes *scr_buffer_concat_dyn_impl(const ScrDyn *list, double total, bool has_total) { + ScrDyn *materialized = NULL; + if (list->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(list) != NULL) { + materialized = scr_dyn_typed_ref_materialize(list); + list = materialized; + } + if (list->kind != SCR_DYN_ARR) { + scr_dyn_arg_type_fail("list", "an instance of Array", list); + scr_dyn_release(materialized); + return NULL; + } + size_t n = list->v.arr.len; + ScrArr *parts = scr_arr_new(SCR_ELEM_BYTES, (double)n); + for (size_t i = 0; i < n; i++) { + ScrDyn *item = scr_dyn_index_get(list, (double)i); + const ScrDyn *shown = item != NULL ? item : scr_dyn_undefined(); + if (!scr_dyn_bytes_is(shown, SCR_BYTES_U8) || shown->v.bytes->is_data_view) { + char name[40]; + snprintf(name, sizeof name, "list[%zu]", i); + scr_dyn_arg_type_fail(name, "an instance of Buffer or Uint8Array", shown); + scr_dyn_release(item); + scr_arr_release(parts); + scr_dyn_release(materialized); + return NULL; + } + scr_arr_push_ref(parts, scr_bytes_retain(shown->v.bytes)); + scr_dyn_release(item); + } + ScrBytes *out = has_total ? scr_buffer_concat_len(parts, total) : scr_buffer_concat(parts); + scr_arr_release(parts); + scr_dyn_release(materialized); + return out; +} + +ScrBytes *scr_buffer_concat_dyn(const ScrDyn *list) { + return scr_buffer_concat_dyn_impl(list, 0, false); +} + +ScrBytes *scr_buffer_concat_len_dyn(const ScrDyn *list, double total) { + return scr_buffer_concat_dyn_impl(list, total, true); +} + ScrBytes *scr_buffer_from_dyn(const ScrDyn *value, const ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_from_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_convert(SCR_BYTES_U8, value->v.bytes); diff --git a/tests/corpus/5973-buffer-concat-untyped.js b/tests/corpus/5973-buffer-concat-untyped.js new file mode 100644 index 000000000..54256c935 --- /dev/null +++ b/tests/corpus/5973-buffer-concat-untyped.js @@ -0,0 +1,20 @@ +// Buffer.concat over an untyped list (JavaScript's `const chunks = []`): +// Uint8Array elements concatenate, the length form pads or truncates, and +// a non-array list or a non-Uint8Array element throws Node's TypeError. +const chunks = []; +chunks.push(Buffer.from("ab")); +chunks.push(new Uint8Array([99, 100])); +console.log(Buffer.concat(chunks).toString()); +console.log(Buffer.concat(chunks, 6)); +console.log(Buffer.concat(chunks, 3).toString()); +console.log(Buffer.concat([]).length); + +const lists = [[1], ["a"], [Buffer.from("x"), null], "x", 5, [new Uint16Array(1)]]; +for (const list of lists) { + try { + Buffer.concat(list); + console.log("no error"); + } catch (e) { + console.log(e.name, e.code, e.message); + } +} diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 24eca7d43..9551e9ad0 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1901,6 +1901,7 @@ "5970-net-connect-host-name.ts": 2400, "5971-buffer-chunks-to-string.ts": 2700, "5972-net-set-encoding-listeners.ts": 2550, + "5973-buffer-concat-untyped.js": 1900, "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, @@ -4588,6 +4589,7 @@ "5970-net-connect-host-name.ts": 2800, "5971-buffer-chunks-to-string.ts": 3100, "5972-net-set-encoding-listeners.ts": 3000, + "5973-buffer-concat-untyped.js": 2900, "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, From 0e67cd1e3bad393b6cc067852c05d367b82ed44c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 08:02:34 -0500 Subject: [PATCH 02/12] Place functions too large to inline by their own name The helper keeps an internal function that exactly one other function references in its referrer's partition, so the partition can inline it. A program's module initializers are each called only from the entry point, so all of them, with every single-use helper they call, shared the entry point's partition. In the three.js nodes fixture that partition held 1.1 million lines of LLVM IR, almost five times the average, and set the native build's longest compile. A function whose body is larger than 256 KiB of LLVM text is past LLVM's inline cost limit even for the only call to a local function, so it is now placed by the hash of its own name. Its own single-use callees still stay with it. --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 +++++----- .../generated/node-v24-backlog.json | 19 +++++++++++-------- .../generated/node-v24-internal.json | 12 ++++++++---- native/llvm-codegen/src/partition.cpp | 10 ++++++++-- native/llvm-codegen/src/partition.h | 4 ++-- 6 files changed, 35 insertions(+), 22 deletions(-) diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 500876593..db36cdb52 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": "56f660f5e2eb7391a048", + "artifactVersion": "cade1b8e6e0c5c42fcc9", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 568cdb4c9..dadc9e836 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -569,9 +569,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -57593,9 +57593,9 @@ "anchor": "", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 683d477a5..12b12d63b 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1142, - "verify-gap": 2187, + "audit-partial": 1143, + "verify-gap": 2186, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -57507,12 +57507,15 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:process", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 393955bc0..2b97f675e 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -596,9 +596,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -80949,8 +80949,12 @@ "anchor": "", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] }, "anchorSource": "chapter" }, diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp index 066ba614c..8ad696d33 100644 --- a/native/llvm-codegen/src/partition.cpp +++ b/native/llvm-codegen/src/partition.cpp @@ -418,7 +418,12 @@ std::optional partitionProgramText(StringRef Text, // An internal function referenced by exactly one other function, and by // no global, is a whole-program inlining candidate; keep it with that - // function so its partition can inline it without importing. + // function so its partition can inline it without importing. A function + // body larger than this is past LLVM's inline cost limit even for the only + // call to a local function, so it is placed by its own name. Otherwise a + // program's module initializers, which only the entry point calls, would + // all share the entry point's partition. + constexpr size_t MaxInlinedBodyBytes = 256 * 1024; constexpr unsigned Shared = ~1u; std::vector Referrer(Symbols.size(), NoPartition); for (unsigned I = 0; I < Symbols.size(); ++I) { @@ -441,7 +446,8 @@ std::optional partitionProgramText(StringRef Text, for (unsigned Steps = 0; Steps < 1024; ++Steps) { const Symbol &S = Symbols[Root]; unsigned Parent = Referrer[Root]; - if (!S.Local || Parent == NoPartition || Parent == Shared || + if (!S.Local || S.Body.size() > MaxInlinedBodyBytes || + Parent == NoPartition || Parent == Shared || Symbols[Parent].Kind != SymbolKind::Function || Parent == I) break; Root = Parent; diff --git a/native/llvm-codegen/src/partition.h b/native/llvm-codegen/src/partition.h index 9d48548fb..838919443 100644 --- a/native/llvm-codegen/src/partition.h +++ b/native/llvm-codegen/src/partition.h @@ -22,8 +22,8 @@ struct ProgramPartitions { // Divides the LLVM assembly scriptc emits into Count modules without parsing // it, so the partitions can be parsed concurrently. Functions are placed by a // stable hash of their name; an internal function referenced by exactly one -// other function stays with that function, and a global stays with the first -// function that references it. Returns nullopt for any construct outside the +// other function stays with that function unless its body is too large to +// inline, and a global stays with the first function that references it. Returns nullopt for any construct outside the // emitter's narrow top-level grammar; callers then split a parsed module. std::optional partitionProgramText(llvm::StringRef Text, unsigned Count); From 57b6a4460c37590a541b6821534369c3c6f7d8d5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 08:03:20 -0500 Subject: [PATCH 03/12] Compare the zlib-shims npm case with Node With untyped Buffer.concat lists and typeof on builtin module exports supported, the fixture runs to completion and matches Node, so its expected deferred SC2020 refusal is removed. --- tests/harness/npm.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index a6cdee2b5..71b8a61d1 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -109,7 +109,6 @@ const KNOWN: Record = { "web-streams": { build: "SC2013" }, "workspace-copied": { build: "SC2013" }, "workspace-unclean": { build: "SC2013" }, - "zlib-shims": { run: "SC2020" }, }; const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name); From 2ae1f72d0bc9613d30325db8f7ecf7e8e4b69b97 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:13:57 -0700 Subject: [PATCH 04/12] Read self-capturing closures through selfRef instead of their box `const visit = (node) => { ... visit(child) ... }` lowered to a closure over the box of `visit`, and the box then held the closure: a two-object reference cycle that only the cycle collector could free, along with everything the closure captured. Nested function declarations already read their own name through selfRef; arrows and function expressions stored into a binding now do too, when the IR proves the binding holds the closure itself whenever the closure can run. resolveSelfCaptures runs with the other backend IR passes, before copyStableCaptures. A capture qualifies when every creation site stores the new closure, unadapted and with the binding's own type, into a boxed local of the creating function (its only declaration's initializer, or the only assignment after an empty declaration in the same statement list), nothing else writes the binding in any body that shares its box, and the closure body is synchronous, not a generator, and does not hand the binding to a nested closure. The capture then leaves the body and every creation site; its reads become selfRef. A creating binding that no other closure captures loses its box, except dead-zone bindings. Self-hosted compiler (native, compiling its CLI to IR): the cycle collector frees 14.5M objects instead of 22.1M; closures that held a box containing themselves drop from 3.5M to 54k. Of the runtime suite and the cg-* cycle workloads, only published-graphs compiles differently (-1.3%, 31 interleaved runs). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 9 +- .../compiler/src/ir/self-captures.test.ts | 206 ++++++++++++++++ packages/compiler/src/ir/self-captures.ts | 229 ++++++++++++++++++ .../5990-self-capturing-closures.ts.json | 6 + tests/corpus/5990-self-capturing-closures.ts | 124 ++++++++++ 5 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/ir/self-captures.test.ts create mode 100644 packages/compiler/src/ir/self-captures.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json create mode 100644 tests/corpus/5990-self-capturing-closures.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2c3a71457..126ff36e3 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -100,6 +100,7 @@ import { } from "../../ir/integer-ranges.js"; import { unshareNodes } from "../../ir/unshare.js"; import { copyStableCaptures } from "../../ir/capture-values.js"; +import { resolveSelfCaptures } from "../../ir/self-captures.js"; import { analyzeInt32Slots, dynamicFieldStores, type Int32Slots } from "../../ir/int32-slots.js"; import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; @@ -486,7 +487,9 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) specializeNumericCalls( foldConstantGlobals( copyStableCaptures( - hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + resolveSelfCaptures( + hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + ), ), ), mangleFunction, @@ -514,7 +517,9 @@ export function emitLlvmModuleSource( specializeNumericCalls( foldConstantGlobals( copyStableCaptures( - hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + resolveSelfCaptures( + hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + ), ), ), mangleFunction, diff --git a/packages/compiler/src/ir/self-captures.test.ts b/packages/compiler/src/ir/self-captures.test.ts new file mode 100644 index 000000000..bd03725aa --- /dev/null +++ b/packages/compiler/src/ir/self-captures.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "vitest"; +import { + F64, + funcOf, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { resolveSelfCaptures } from "./self-captures.js"; + +const loc = { file: "self.ts", start: 0, end: 0 }; +const FN = funcOf([], F64); +const ref = (localId: string, type = FN): IrExpr => ({ kind: "varRef", localId, type, loc }); +const call = (localId: string): IrExpr => ({ + kind: "callValue", + callee: ref(localId), + args: [], + type: F64, + loc, +}); +const self: IrExpr = { kind: "selfRef", type: FN, loc }; +const closure = (fnName: string, captures: string[], extra: object = {}): IrExpr => ({ + kind: "closure", + fnName, + captures, + type: FN, + loc, + ...extra, +}); +const decl = (localId: string, init: IrExpr | null): IrStmt => ({ + kind: "varDecl", + localId, + init, + loc, +}); +const assign = (localId: string, value: IrExpr, initializes = false): IrStmt => ({ + kind: "assign", + localId, + value, + ...(initializes ? { initializes: true as const } : {}), + loc, +}); +const ret = (e: IrExpr): IrStmt => ({ kind: "return", value: e, loc }); +const use = (e: IrExpr): IrStmt => ({ kind: "exprStmt", expr: e, loc }); +const local = (id: string, extra: Partial = {}): IrLocal => ({ + id, + name: id.split(".")[0]!, + type: FN, + mutable: false, + boxed: true, + ...extra, +}); + +function outer(body: IrStmt[], locals: IrLocal[]): IrFunction { + return { name: "outer", params: [], returnType: F64, locals, body, loc }; +} +function lifted(name: string, body: IrStmt[], captured: IrLocal[]): IrFunction { + return { + name, + params: [], + returnType: F64, + captures: captured.map((l) => ({ localId: l.id, name: l.name, type: l.type })), + locals: captured, + body, + loc, + }; +} +function run(...functions: IrFunction[]): Map { + const mod = { functions } as unknown as IrModule; + return new Map(resolveSelfCaptures(mod).functions.map((fn) => [fn.name, fn])); +} + +test("a closure stored in the dead-zone binding it captures reads itself", () => { + const fns = run( + outer( + [decl("f.0", null), assign("f.0", closure("walk", ["f.0"]), true), ret(call("f.0"))], + [local("f.0", { mutable: true, tdz: true })], + ), + lifted("walk", [ret(call("f.0"))], [local("f.0", { tdz: true })]), + ); + const walk = fns.get("walk")!; + expect(walk.captures).toEqual([]); + expect(walk.locals).toEqual([]); + expect(walk.body).toEqual([ret({ kind: "callValue", callee: self, args: [], type: F64, loc })]); + const origin = fns.get("outer")!; + expect(origin.body[1]).toEqual(assign("f.0", closure("walk", []), true)); + // The dead-zone box still represents the binding's uninitialized state. + expect(origin.locals[0]).toMatchObject({ boxed: true, tdz: true }); +}); + +test("a binding that only held the closure loses its box", () => { + const n = local("n.0", { type: F64 }); + const fns = run( + outer( + [ + decl("n.0", { kind: "numLit", value: 1, type: F64, loc }), + decl("f.0", closure("walk", ["n.0", "f.0"])), + ret(call("f.0")), + ], + [n, local("f.0")], + ), + lifted("walk", [use(ref("n.0", F64)), ret(call("f.0"))], [n, local("f.0")]), + ); + expect(fns.get("walk")!.captures!.map((c) => c.localId)).toEqual(["n.0"]); + const origin = fns.get("outer")!; + expect(origin.body[1]).toEqual(decl("f.0", closure("walk", ["n.0"]))); + expect(origin.locals.find((l) => l.id === "f.0")!.boxed).toBeUndefined(); + expect(origin.locals.find((l) => l.id === "n.0")!.boxed).toBe(true); +}); + +test("hoisted declarations qualify; later writes and second sites do not", () => { + const body = (extra: IrStmt[]): IrStmt[] => [ + decl("f.0", null), + assign("f.0", closure("walk", ["f.0"])), + ...extra, + ret(call("f.0")), + ]; + const walk = lifted("walk", [ret(call("f.0"))], [local("f.0")]); + const hoisted = run(outer(body([]), [local("f.0", { mutable: true })]), walk); + expect(hoisted.get("walk")!.captures).toEqual([]); + expect(hoisted.get("outer")!.locals[0]!.boxed).toBeUndefined(); + + const rewritten = run( + outer(body([assign("f.0", closure("other", []))]), [local("f.0", { mutable: true })]), + walk, + lifted("other", [ret({ kind: "numLit", value: 0, type: F64, loc })], []), + ); + expect(rewritten.get("walk")).toBe(walk); + + // The same body stored somewhere else: its environment must keep the box. + const elsewhere = run( + outer( + [...body([]).slice(0, 2), use(closure("walk", ["f.0"])), ret(call("f.0"))], + [local("f.0", { mutable: true })], + ), + walk, + ); + expect(elsewhere.get("walk")).toBe(walk); +}); + +test("an assignment in another statement list, an adapter or a nested capture keeps the box", () => { + const walk = lifted("walk", [ret(call("f.0"))], [local("f.0")]); + const branch = run( + outer( + [ + decl("f.0", null), + { kind: "block", body: [assign("f.0", closure("walk", ["f.0"]))], loc }, + ret(call("f.0")), + ], + [local("f.0", { mutable: true })], + ), + walk, + ); + expect(branch.get("walk")).toBe(walk); + + const adapter = run( + outer( + [decl("f.0", closure("walk", ["f.0"], { adapts: true })), ret(call("f.0"))], + [local("f.0")], + ), + walk, + ); + expect(adapter.get("walk")).toBe(walk); + + const nested = lifted("walk", [use(closure("inner", ["f.0"])), ret(call("f.0"))], [local("f.0")]); + const recaptured = run( + outer([decl("f.0", closure("walk", ["f.0"])), ret(call("f.0"))], [local("f.0")]), + nested, + lifted("inner", [ret(call("f.1"))], [local("f.1")]), + ); + expect(recaptured.get("walk")).toBe(nested); +}); + +test("mutually recursive closures only drop their own capture", () => { + const fns = run( + outer( + [ + decl("a.0", null), + decl("b.0", null), + assign("a.0", closure("fa", ["a.0", "b.0"]), true), + assign("b.0", closure("fb", ["a.0", "b.0"]), true), + ret(call("a.0")), + ], + [local("a.0", { mutable: true, tdz: true }), local("b.0", { mutable: true, tdz: true })], + ), + lifted( + "fa", + [use(call("b.0")), ret(call("a.0"))], + [local("a.0", { tdz: true }), local("b.0", { tdz: true })], + ), + lifted( + "fb", + [use(call("a.0")), ret(call("b.0"))], + [local("a.0", { tdz: true }), local("b.0", { tdz: true })], + ), + ); + expect(fns.get("fa")!.captures!.map((c) => c.localId)).toEqual(["b.0"]); + expect(fns.get("fb")!.captures!.map((c) => c.localId)).toEqual(["a.0"]); + expect(fns.get("outer")!.body.slice(2)).toEqual([ + assign("a.0", closure("fa", ["b.0"]), true), + assign("b.0", closure("fb", ["a.0"]), true), + ret(call("a.0")), + ]); +}); diff --git a/packages/compiler/src/ir/self-captures.ts b/packages/compiler/src/ir/self-captures.ts new file mode 100644 index 000000000..e009e9a11 --- /dev/null +++ b/packages/compiler/src/ir/self-captures.ts @@ -0,0 +1,229 @@ +import { CaptureFlow } from "./capture-values.js"; +import { + typeEquals, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { everyExprChild, everyStmtChild, mapExprChildren, mapStmtChildren } from "./traverse.js"; + +/** Closures that capture the binding they are stored in read themselves + * through `selfRef` instead of through that binding's box. + * + * `const visit = (node) => { ... visit(child) ... }` lowers to a closure + * over the box of `visit`, and the box then holds the closure: a two-object + * reference cycle that reference counting cannot free. Each closure made + * this way is buffered as a cycle candidate and reclaimed only when a + * collector pass walks it. Nested function declarations already read their + * own name through `selfRef`; this pass gives arrows and function + * expressions bound to a binding the same form once the IR proves that the + * binding always holds the closure itself while the closure can run: + * + * - every creation site of the closure body stores the new closure, as is, + * into a boxed local of the creating function: either the initializer of + * that local's only declaration, or the only assignment of a local whose + * only declaration is empty and precedes the assignment in the same + * statement list (so each box is filled once, and a closure can only run + * after the statement that stores it); + * - nothing else writes the binding, in the creating function or in any + * body that shares its box (CaptureFlow.unchanged), and no local class + * captures it; + * - the closure body is synchronous, is not a generator, and does not pass + * the binding on to a nested closure (its environment would need a box of + * its own, allocated on every call). + * + * The capture then disappears from the body and from every creation site, + * and each read of it becomes `selfRef` (the running closure, retained), + * which is the same pointer the box would have produced. The creating + * function keeps its binding; it loses its box when no other closure + * captures it, except for temporal-dead-zone bindings, whose box still + * carries the empty state its own reads test. */ +export function resolveSelfCaptures(mod: IrModule): IrModule { + const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); + const sites = new Map(); + /** Closure nodes stored into a local by a qualifying definition, with that + * local and the number of writes the definition itself makes. */ + const definitions = new Map(); + const declarations = new Map>(); + for (const fn of mod.functions) { + const counts = new Map(); + declarations.set(fn.name, counts); + const expr = (e: IrExpr): boolean => { + if (e.kind === "closure") { + let list = sites.get(e.fnName); + if (list === undefined) sites.set(e.fnName, (list = [])); + list.push({ owner: fn.name, closure: e }); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "varDecl") counts.set(s.localId, (counts.get(s.localId) ?? 0) + 1); + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + for (const list of statementLists(fn.body)) { + list.forEach((s, i) => { + if (s.kind !== "varDecl") return; + if (s.init !== null) { + if (s.init.kind === "closure") definitions.set(s.init, { local: s.localId, writes: 0 }); + return; + } + for (let j = i + 1; j < list.length; j++) { + const next = list[j]!; + if (next.kind === "assign" && next.localId === s.localId) { + if (next.value.kind === "closure") + definitions.set(next.value, { local: s.localId, writes: 1 }); + return; + } + } + }); + } + } + + let flow: CaptureFlow | null = null; + /** Per closure body, the capture index that names its own closure. */ + const resolved = new Map(); + for (const fn of mod.functions) { + const captures = fn.captures; + if (captures === undefined || captures.length === 0) continue; + if (fn.async === true || fn.generator !== undefined || fn.classCaptures !== undefined) continue; + const list = sites.get(fn.name); + if (list === undefined || list.length === 0) continue; + const recaptured = capturedIds(fn.body); + for (let index = 0; index < captures.length; index++) { + const capture = captures[index]!; + if (capture.type.kind !== "func" || recaptured.has(capture.localId)) continue; + const qualifies = list.every(({ owner, closure }) => { + if (closure.adapts === true || !typeEquals(closure.type, capture.type)) return false; + const definition = definitions.get(closure); + const source = closure.captures[index]; + if (definition === undefined || source !== definition.local) return false; + const creator = functions.get(owner)!; + const local = creator.locals.find((l) => l.id === source); + if (local?.boxed !== true || !typeEquals(local.type, closure.type)) return false; + if (creator.params.some((p) => p.localId === source)) return false; + if (creator.captures?.some((c) => c.localId === source)) return false; + if (creator.classCaptures?.some((c) => c.localId === source)) return false; + if (declarations.get(owner)!.get(source) !== 1) return false; + flow ??= new CaptureFlow(mod.functions); + return flow.unchanged(owner, source, definition.writes); + }); + if (qualifies) { + resolved.set(fn.name, index); + break; + } + } + } + if (resolved.size === 0) return mod; + + const rewritten = mod.functions.map((fn) => { + const own = resolved.get(fn.name); + const self = own === undefined ? null : fn.captures![own]!; + let changed = false; + const expr = (e: IrExpr): IrExpr => { + if (self !== null && e.kind === "varRef" && e.localId === self.localId) { + changed = true; + return { kind: "selfRef", type: self.type, loc: e.loc }; + } + if (e.kind === "closure") { + const drop = resolved.get(e.fnName); + if (drop !== undefined) { + changed = true; + return { ...e, captures: e.captures.filter((_, i) => i !== drop) }; + } + } + return mapExprChildren(e, expr, stmt); + }; + const stmt = (s: IrStmt): IrStmt => mapStmtChildren(s, expr, stmt); + const body = fn.body.map(stmt); + if (!changed && self === null) return fn; + // A binding no environment captures any more needs no box. A dead-zone + // binding keeps the box that represents its uninitialized state. + const stillCaptured = capturedIds(body); + // The callback returns IrLocal explicitly: inferred from both returns, + // its type would be the unboxed copy's, and the self-hosted compiler + // would narrow every kept local to that shape, dropping `boxed`. + const locals = fn.locals + .filter((local) => self === null || local.id !== self.localId) + .map((local): IrLocal => { + if (local.boxed !== true || local.tdz === true || stillCaptured.has(local.id)) return local; + if (fn.captures?.some((c) => c.localId === local.id)) return local; + if (fn.classCaptures?.some((c) => c.localId === local.id)) return local; + if (!wasSource(fn, local.id, resolved)) return local; + const { boxed: _boxed, ...plain } = local; + return plain; + }); + const next: IrFunction = { ...fn, locals, body }; + if (self !== null) next.captures = fn.captures!.filter((_, i) => i !== own); + return next; + }); + return { ...mod, functions: rewritten }; +} + +/** Whether `fn` stored a resolved closure into `id`, the only case in which + * this pass may unbox a binding (others stay exactly as lowered). */ +function wasSource(fn: IrFunction, id: string, resolved: ReadonlyMap): boolean { + let found = false; + const expr = (e: IrExpr): boolean => { + if (e.kind === "closure") { + const index = resolved.get(e.fnName); + if (index !== undefined && e.captures[index] === id) found = true; + } + return !found && everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + fn.body.every(stmt); + return found; +} + +/** Locals some closure or class created in `body` captures. */ +function capturedIds(body: readonly IrStmt[]): Set { + const ids = new Set(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "closure" || e.kind === "classRef") + for (const id of e.captures ?? []) ids.add(id); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + body.every(stmt); + return ids; +} + +/** `body` and every statement list nested in its statements (not the ones + * inside expressions). Statements of one list run in order, each at most + * once per execution of the list. */ +function statementLists(body: readonly IrStmt[]): (readonly IrStmt[])[] { + const lists: (readonly IrStmt[])[] = []; + const visit = (list: readonly IrStmt[]): void => { + lists.push(list); + for (const s of list) { + switch (s.kind) { + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + visit(s.body); + break; + case "if": + visit(s.then); + if (s.else_ !== null) visit(s.else_); + break; + case "switch": + for (const c of s.cases) visit(c.body); + break; + case "tryCatch": + visit(s.tryBody); + if (s.catchBody !== null) visit(s.catchBody); + if (s.finallyBody !== null) visit(s.finallyBody); + break; + default: + break; + } + } + }; + visit(body); + return lists; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json new file mode 100644 index 000000000..5f707aaf2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5990-self-capturing-closures.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5990-self-capturing-closures.ts b/tests/corpus/5990-self-capturing-closures.ts new file mode 100644 index 000000000..274e7b8d7 --- /dev/null +++ b/tests/corpus/5990-self-capturing-closures.ts @@ -0,0 +1,124 @@ +// Closures that call themselves through the binding they are stored in: +// const arrows, function expressions, closures made in loops, closures that +// also hand themselves to nested callbacks, and a binding that is later +// rebound (its closure must see the new value). Each read of the binding +// inside the body is the same function object the binding holds. The +// sanitized lane's RC audit fails if any of these closures leaks or is +// freed while reachable. + +const fact = (n: number): number => (n <= 1 ? 1 : n * fact(n - 1)); +console.log(fact(10)); + +const pick = (n: number): (() => number) | null => (n === 0 ? null : () => n); +const identity = (depth: number): unknown => (depth === 0 ? identity : identity(depth - 1)); +console.log(identity(3) === identity, pick(0) === null, pick(2)!()); + +const fib = function (n: number): number { + return n < 2 ? n : fib(n - 1) + fib(n - 2); +}; +console.log(fib(15)); + +const outerName = function innerName(n: number): number { + return n <= 0 ? 0 : 1 + outerName(n - 1) + innerName(0); +}; +console.log(outerName(5)); + +// One closure per iteration, each recursing into itself and reading its +// own iteration's value. +const perIteration: ((n: number) => number)[] = []; +for (let i = 0; i < 4; i++) { + const step = (n: number): number => (n === 0 ? i : step(n - 1) + 10); + perIteration.push(step); +} +console.log(perIteration.map((f) => f(2)).join(" ")); +console.log(perIteration[0] === perIteration[1], perIteration[2] === perIteration[2]); + +// The closure also passes itself to nested callbacks. +interface Tree { + value: number; + kids: Tree[]; +} +const leaf = (value: number): Tree => ({ value, kids: [] }); +const tree: Tree = { + value: 1, + kids: [{ value: 2, kids: [leaf(3), leaf(4)] }, leaf(5)], +}; +const sum = (node: Tree): number => node.value + node.kids.map((kid) => sum(kid)).reduce((a, b) => a + b, 0); +console.log(sum(tree)); +const visited: number[] = []; +const walk = (node: Tree): void => { + visited.push(node.value); + node.kids.forEach(walk); +}; +walk(tree); +console.log(visited.join(",")); + +// Captured state next to the self reference. +function counterTo(limit: number): () => number { + let calls = 0; + const tick = (): number => { + calls++; + return calls < limit ? tick() : calls; + }; + return tick; +} +const tick = counterTo(5); +console.log(tick(), tick()); + +// A rebound binding: the first closure calls whatever the binding holds now. +let rebound = (n: number): number => (n <= 0 ? 0 : 1 + rebound(n - 1)); +const first = rebound; +console.log(first(3)); +rebound = (n: number): number => n * 100; +console.log(first(3), rebound(3)); + +// Mutual recursion keeps both bindings live. +const isEven = (n: number): boolean => (n === 0 ? true : isOdd(n - 1)); +const isOdd = (n: number): boolean => (n === 0 ? false : isEven(n - 1)); +console.log(isEven(10), isOdd(7), isEven(3)); + +// Identity as a map key from inside the body. +const seen = new Map(); +const keyed = (n: number): number => { + seen.set(keyed, (seen.get(keyed) ?? 0) + 1); + return n === 0 ? seen.size : keyed(n - 1); +}; +console.log(keyed(4), seen.get(keyed)); + +// An object that holds a closure capturing the object (a real cycle). +class Widget { + label: string; + onChange: (() => string) | null = null; + constructor(label: string) { + this.label = label; + } +} +function widgets(count: number): number { + let total = 0; + for (let i = 0; i < count; i++) { + const widget = new Widget(`w${i}`); + const describe = (depth: number): string => (depth === 0 ? widget.label : describe(depth - 1)); + widget.onChange = () => describe(2); + total += widget.onChange().length; + } + return total; +} +console.log(widgets(1000)); + +// Many short-lived recursive closures. +let checksum = 0; +for (let round = 0; round < 20000; round++) { + const down = (n: number): number => (n === 0 ? round % 7 : down(n - 1)); + checksum += down(3); +} +console.log(checksum); + +// Generators and async functions that name themselves. +const countdown = function* (n: number): Generator { + if (n < 0) return; + yield n; + yield* countdown(n - 1); +}; +console.log([...countdown(3)].join("")); +const later = async (n: number): Promise => (n === 0 ? 0 : 1 + (await later(n - 1))); +later(3).then((value) => console.log("async", value)); From 989d0d6bc2d03ad2202c974143533b3d033c8bb6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 09:12:14 -0700 Subject: [PATCH 05/12] Buffer nursery cycle candidates on the release fast paths Every release that left a headered object alive and unbuffered called scr_cyc_on_release, which looked up the type's bypass statistics, pushed the object, kept a running total and compared it with two triggers. The nursery case now has a fast path that appends the object to the nursery buffer itself, while the runtime-maintained window says the append can neither make a collection due nor grow the buffer, and the live count is below the allocation trigger: - scr_rc.h exports the candidate buffers (scr_cyc_buffers: the three buffers, the window and the allocation trigger) where the collector state is ordinary globals, and scr_cyc_append. The runtime's own release paths (scr_cyc_released) append inline, and scr_cyc_on_release tries the append first. - Optimized emitted code buffers through one sc_cyc_append helper per module, called with preserve_mostcc, so release fast paths keep the size of the plain call they had (inlining the append into every release fast path grew the self-hosted compiler's text by 4.6%) and their callers keep their registers. Its runtime call goes through a second preserve_mostcc function, which keeps the register saves off the append path. - scr_cycle.c keeps the window: recomputed where buffers, capacity or the trigger change in bulk, lowered by one on older buffering and nursery deaths, 0 during passes. The running total is gone; the slow path sums the buffers. - An appended candidate whose type bypasses the nursery would have been buffered past it. scr_cyc_route_bypassed moves such candidates before any scheduling decision or pass reads the buffers, consuming the bypass counts as scr_cyc_on_release does (skipped while no type bypasses). test_cycle checks the window (the release that reaches the trigger still collects) and the routing of inline-buffered candidates of a bypassed type; its mature-bypass probe routes before reading generations. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/alloc.ts | 91 ++++++++++ .../compiler/src/backend/llvm/shapes.test.ts | 37 ++++ packages/compiler/src/backend/llvm/shapes.ts | 57 +++++- packages/runtime/src/scr_cycle.c | 167 ++++++++++++++---- packages/runtime/src/scr_rc.h | 68 ++++++- packages/runtime/test/test_cycle.c | 74 +++++++- 6 files changed, 450 insertions(+), 44 deletions(-) diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index 7dfc385aa..72631ba1c 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -141,6 +141,97 @@ function declareGlobal(host: AllocHost, decl: string): void { if (host.threadLocalAlloc !== true) host.declare(decl); } +/** `scr_cyc_buffers` (scr_rc.h ScrCycBuffers): the three candidate buffers + * `{ v, n, cap, used }`, the inline buffering window and the allocation + * trigger. */ +const CYC_BUFFERS_TYPE = "{ [3 x { ptr, i64, i64, i64 }], i64, i64 }"; +/** Byte offsets in it (asserted in scr_rc.h). */ +const CYC_NURSERY_COUNT = 8; +const CYC_WINDOW = 96; +const CYC_ALLOC_TRIGGER = 104; + +/** Whether emitted release paths can append nursery candidates to the + * collector's buffer themselves (through {@link cycleAppendHelper}): where + * the runtime keeps that state in ordinary globals (executables without + * workers and classic libraries, on the 64-bit targets with inline + * allocation). */ +export function inlineBufferingSupported(host: AllocHost): boolean { + return host.inlineAlloc === true && host.threadLocalAlloc !== true && host.sizeType === "i64"; +} + +/** The emitted symbol of {@link cycleAppendHelper}. */ +export const CYC_APPEND = "sc_cyc_append"; + +/** The runtime symbols {@link cycleAppendHelper} uses. The helper is + * defined after the module's extern block, so its caller declares them. */ +export function cycleAppendDecls(): string[] { + return [ + `@scr_cyc_buffers = external global ${CYC_BUFFERS_TYPE}`, + `@scr_cyc_live = external global i64`, + `declare void @scr_cyc_on_release(ptr)`, + ]; +} + +/** The candidate-buffering step of every emitted release that leaves a + * headered object alive and unbuffered: scr_rc.h's scr_cyc_append, then + * scr_cyc_on_release when it declines. A nursery object goes onto the + * nursery buffer while the buffer holds fewer entries than the runtime's + * window and the live count is below the allocation trigger. + * + * One out-of-line definition per module keeps the release fast paths, which + * LLVM inlines into their callers, as small as the plain call they had. It + * uses preserve_mostcc, so the call clobbers almost no caller registers: + * the callers' code around a release stays what it was, and the append + * itself saves only the few registers it uses. Its slow path enters the + * runtime through a second preserve_mostcc function, which saves the rest. */ +export function cycleAppendHelper(): string[] { + return [ + `define internal preserve_mostcc void @${CYC_APPEND}(ptr %o) noinline #0 { ; scr_cyc_append, else scr_cyc_on_release`, + `entry:`, + ` %genp = getelementptr i8, ptr %o, i64 -${CYC_HDR - CYC_GEN}`, + ` %gen = load i8, ptr %genp ; ScrCycHdr.gen`, + ` %young = icmp eq i8 %gen, 0`, + ` br i1 %young, label %window, label %call`, + `window:`, + ` %np = getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 ${CYC_NURSERY_COUNT}`, + ` %n = load i64, ptr %np ; nursery buffer entries`, + ` %wp = getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 ${CYC_WINDOW}`, + ` %w = load i64, ptr %wp ; window`, + ` %room = icmp ult i64 %n, %w`, + ` br i1 %room, label %live, label %call`, + `live:`, + ` %lv = load i64, ptr @scr_cyc_live`, + ` %tp = getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 ${CYC_ALLOC_TRIGGER}`, + ` %t = load i64, ptr %tp ; allocation trigger`, + ` %due = icmp uge i64 %lv, %t`, + ` br i1 %due, label %call, label %append`, + `append:`, + ` %v = load ptr, ptr @scr_cyc_buffers`, + ` %slot = getelementptr inbounds ptr, ptr %v, i64 %n`, + ` store ptr %o, ptr %slot`, + ` %n1 = add i64 %n, 1`, + ` store i64 %n1, ptr %np`, + ` %i = trunc i64 %n to i32`, + ` %b = or i32 %i, -2147483648 ; SCR_CYC_BUFFERED_BIT | position`, + ` %bufp = getelementptr i8, ptr %o, i64 -${CYC_HDR - CYC_BUF}`, + ` store i32 %b, ptr %bufp`, + ` ret void`, + `call:`, + ` tail call preserve_mostcc void @${CYC_APPEND}_call(ptr %o)`, + ` ret void`, + `}`, + // The runtime call clobbers every caller-saved register, which this + // preserve_mostcc function must restore: saving them here keeps those + // saves out of the append path above. + `define internal preserve_mostcc void @${CYC_APPEND}_call(ptr %o) noinline #0 {`, + `entry:`, + ` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`, + ` ret void`, + `}`, + ``, + ]; +} + /** A collector header to install: the object type's constant ScrCycType * descriptor (its trace and teardown; see cycTypeDescriptor). */ export interface CycHeader { diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts index 8c2c46541..f25dca3a3 100644 --- a/packages/compiler/src/backend/llvm/shapes.test.ts +++ b/packages/compiler/src/backend/llvm/shapes.test.ts @@ -205,6 +205,43 @@ describe("LLVM runtime RC symbols", () => { expect(fn("sc_rc_retain_promise")).not.toContain("store i32"); }); + test("optimized builds buffer candidates through the module's append helper", () => { + const emit = (inlineRc: true | "release", extra: Partial) => { + const { host } = declarationHost(64, inlineRc); + const configured: ShapeHost = { ...host, inlineAlloc: true, ...extra }; + releaseSym(configured, { kind: "dyn" }); + return { + ir: emitInlineRcHelpers(configured).join("\n"), + decls: inlineRcDecls(configured), + }; + }; + for (const mode of [true, "release"] as const) { + const { ir, decls } = emit(mode, {}); + expect(ir).toContain("call preserve_mostcc void @sc_cyc_append(ptr %o)"); + expect(ir).toContain("define internal preserve_mostcc void @sc_cyc_append(ptr %o) noinline"); + // The young test reads the generation byte at obj-5; the append fills + // scr_cyc_buffers' nursery buffer and the header's position at obj-4. + expect(ir).toContain("getelementptr i8, ptr %o, i64 -5"); + expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 96"); + expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 104"); + expect(ir).toContain("load i64, ptr @scr_cyc_live"); + expect(ir).toContain("or i32 %i, -2147483648"); + expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)"); + expect(decls).toContain( + "@scr_cyc_buffers = external global { [3 x { ptr, i64, i64, i64 }], i64, i64 }", + ); + expect(decls).toContain("@scr_cyc_live = external global i64"); + } + // Worker executables and targets without inline allocation call + // scr_cyc_on_release, which tries the same append first. + for (const extra of [{ threadLocalAlloc: true }, { inlineAlloc: false }]) { + const { ir, decls } = emit(true, extra); + expect(ir).not.toContain("sc_cyc_append"); + expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)"); + expect(decls.join("\n")).not.toContain("scr_cyc_buffers"); + } + }); + test("uses the wasm32 header offsets", () => { const { host } = declarationHost(32, true); releaseSym(host, { kind: "union", unionId: "u0" } as never); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 23565eff4..b62e7d034 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -28,7 +28,15 @@ import { mangleRecordStruct, mangleRecordTrace, } from "../mangle.js"; -import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js"; +import { + cycTypeDescriptor, + emitObjectAlloc, + emitObjectFree, + CYC_APPEND, + cycleAppendDecls, + cycleAppendHelper, + inlineBufferingSupported, +} from "./alloc.js"; import type { BlockBuilder } from "./blocks.js"; import { SCR_ARR_FIELD, llvmCommentText } from "./common.js"; import type { NullableUnions } from "./nullable-unions.js"; @@ -308,6 +316,10 @@ export const INLINE_RC_FAMILIES: Readonly> = { type RcOp = "retain" | "release"; +/** The helper key (with the "family:op" keys) of the module's candidate + * append helper (alloc.ts cycleAppendHelper). */ +const CYC_APPEND_KEY = "cyc:append"; + /** The mirrored family for an RC operation on a runtime stem (`scr_str`), * or null when it stays a call. */ export function inlineRcFamily(host: ShapeHost, stem: string, op: RcOp): InlineRcFamily | null { @@ -369,6 +381,10 @@ function requestRcHelper(host: ShapeHost, family: string, op: RcOp): string { } host.rcHelpers.add(`${family}:${op}`); for (const decl of rcHelperDecls(family, op)) host.declare(decl); + // Its body is written last (emitInlineRcHelpers), after the requests are + // collected: a release that can buffer a candidate requests the append + // helper now. + if (op === "release" && INLINE_RC_FAMILIES[family]!.cycle === "always") requestCycleAppend(host); return `@${inlineRcName(family, op)}`; } @@ -466,6 +482,10 @@ function rcHelperKeys(host: ShapeHost): string[] { export function inlineRcDecls(host: ShapeHost): string[] { const decls = new Set(); for (const key of rcHelperKeys(host)) { + if (key === CYC_APPEND_KEY) { + for (const decl of cycleAppendDecls()) decls.add(decl); + continue; + } const [family, op] = key.split(":") as [string, RcOp]; for (const decl of rcHelperDecls(family, op)) decls.add(decl); } @@ -611,6 +631,10 @@ function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): st export function emitInlineRcHelpers(host: ShapeHost): string[] { const out: string[] = []; for (const key of rcHelperKeys(host).sort()) { + if (key === CYC_APPEND_KEY) { + out.push(...cycleAppendHelper()); + continue; + } const [family, op] = key.split(":") as [string, RcOp]; const row = INLINE_RC_FAMILIES[family]!; out.push( @@ -1254,12 +1278,6 @@ export function releaseFastPath( ]; } -/** The possible-cycle-root step of a release that left a headered object - * alive: scr_cyc_on_release with its early return inlined. Test the sign - * of `buf` (the i32 at obj-4; negative for a queued candidate or a tenured - * object) and call the runtime only to enqueue a new candidate. - * Every emitted release of a headered object shares this sequence - * (scr_runtime.h asserts the offset); it ends by branching to `done`. */ /** One traced field of an emitted trace function: load the slot and visit * it unless it is null. Every collector visitor ignores null children, and * most traced slots of a typical heap are empty (optional links, unset @@ -1283,16 +1301,39 @@ export function traceFieldVisitLines( ]; } +/** Whether candidate buffering goes through the module's append helper + * (alloc.ts cycleAppendHelper), requesting its definition if so: in + * optimized builds (those with inline RC helpers) that support it. */ +function requestCycleAppend(host: ShapeHost): boolean { + if (host.rcHelpers === null || !inlineBufferingSupported(host)) return false; + if (!host.rcHelpers.has(CYC_APPEND_KEY)) { + host.rcHelpers.add(CYC_APPEND_KEY); + for (const decl of cycleAppendDecls()) host.declare(decl); + } + return true; +} + +/** The possible-cycle-root step of a release that left a headered object + * alive: scr_cyc_on_release with its early return inlined. Test the sign + * of `buf` (the i32 at obj-4; negative for a queued candidate or a tenured + * object), and buffer a new candidate: through the module's + * cycleAppendHelper in optimized builds that support it, through + * scr_cyc_on_release otherwise. Every emitted release of a headered object + * shares this sequence (scr_runtime.h asserts the offsets); it ends by + * branching to `done`. */ export function cycleRootLines(host: ShapeHost, done: string): string[] { const S = host.sizeType; host.declare(`declare void @scr_cyc_on_release(ptr)`); + const append = requestCycleAppend(host); return [ ` %bufp = getelementptr i8, ptr %o, ${S} -${host.cycleBufOffset}`, ` %buf = load i32, ptr %bufp`, ` %queued = icmp slt i32 %buf, 0`, ` br i1 %queued, label %${done}, label %enqueue`, `enqueue:`, - ` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`, + append + ? ` call preserve_mostcc void @${CYC_APPEND}(ptr %o) ; buffer the candidate; may collect` + : ` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`, ` br label %${done}`, ]; } diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index c2be13a03..974c2e66d 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -156,14 +156,11 @@ static void scr_cyc_oom(void) { * struct on purpose: buffering a candidate is the hot path, and splitting * them across three arrays indexed by generation would touch three cache * lines per release where the original single buffer touched one. */ -typedef struct { - void **v; - size_t n, cap; - /* The most entries a pass held, for the work vectors a pass fills - * (scr_cyc_push_work); scr_cyc_pass_done frees a large vector that the - * pass used little of. */ - size_t used; -} ScrVec; +/* Its layout is scr_rc.h's ScrCycBuffer (the candidate buffers are part of + * the inline buffering ABI). `used`: the most entries a pass held, for the + * work vectors a pass fills (scr_cyc_push_work); scr_cyc_pass_done frees a + * large vector that the pass used little of. */ +typedef ScrCycBuffer ScrVec; typedef struct { void *object; @@ -201,7 +198,6 @@ typedef struct { unsigned char growth_shift[SCR_CYC_NGENS]; unsigned char growth_streak[SCR_CYC_NGENS]; unsigned backlog_shift[SCR_CYC_NGENS]; - size_t nbuffered; size_t trigger; size_t alloc_trigger; size_t gray_referenced; @@ -235,7 +231,6 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_growth_shift (scr_cyc_thread.growth_shift) #define scr_cyc_growth_streak (scr_cyc_thread.growth_streak) #define scr_cyc_backlog_shift (scr_cyc_thread.backlog_shift) -#define scr_cyc_nbuffered (scr_cyc_thread.nbuffered) #define scr_cyc_trigger (scr_cyc_thread.trigger) #define scr_cyc_alloc_trigger (scr_cyc_thread.alloc_trigger) #define scr_gray_referenced (scr_cyc_thread.gray_referenced) @@ -361,7 +356,16 @@ static inline void scr_cyc_note_used(ScrVec *vec) { /* ── the candidate-root buffers (one per generation) ──────────────────── */ #ifndef SCR_WORKERS -static SCR_TL ScrVec scr_roots[SCR_CYC_NGENS]; +/* Emitted and inline releases append nursery candidates to these buffers + * directly where the build allows it (scr_rc.h). Explicitly initialized so + * library localization can demote it (see scr_cyc_types). */ +#if SCR_CYC_INLINE_BUFFERING +ScrCycBuffers scr_cyc_buffers = {0}; +#else +static SCR_TL ScrCycBuffers scr_cyc_buffers; +#endif +#define scr_roots (scr_cyc_buffers.roots) +#define scr_cyc_alloc_trigger (scr_cyc_buffers.alloc_trigger) static SCR_TL bool scr_collecting = false; #endif @@ -409,6 +413,7 @@ static SCR_TL unsigned scr_gen_limit = SCR_CYC_OLD; #endif static size_t scr_cyc_pass(unsigned gen_limit); +static void scr_cyc_route_bypassed(void); /* A limited pass (scr_cyc_collect_set) takes only the candidates in this * set from the buffers up to `scr_cyc_take_gen`; NULL takes them all. */ static SCR_TL const ScrCycSet *scr_cyc_take = NULL; @@ -505,18 +510,18 @@ static size_t scr_cyc_nursery_threshold(void) { return cached; } -/* Buffered candidates across every generation, and the count at which the - * release path next collects. Keeping a running total (rather than summing - * the buffers) is what holds the hot path to one load and one compare, as - * it was when there was a single buffer. The trigger is re-armed at the end - * of every pass to "whatever survived, plus a nursery's worth": without that +/* The number of buffered candidates (across every generation) at which the + * release path next collects, and the live count at which it collects + * however few candidates there are. The trigger is re-armed at the end of + * every pass to "whatever survived, plus a nursery's worth": without that * hysteresis a large older buffer — which a nursery pass cannot drain — * would re-arm on every single release. Both start at zero, so the first - * release runs one trivial pass that arms them properly. */ + * release runs one trivial pass that arms them properly. The inline + * appends (scr_rc.h) compare against the window scr_cyc_window_update + * derives from these, so the slow path sums the buffers when it needs the + * total. */ #ifndef SCR_WORKERS -static SCR_TL size_t scr_cyc_nbuffered = 0; static SCR_TL size_t scr_cyc_trigger = 0; -static SCR_TL size_t scr_cyc_alloc_trigger = 0; #endif /* Gray objects whose count is above zero after the trial decrements so far * (see scr_mg_visit). markGray records the gray objects in scr_white while @@ -549,6 +554,34 @@ static size_t scr_cyc_buffered(void) { + scr_roots[SCR_CYC_OLD].n; } +/* The inline buffering window (scr_rc.h): how many entries the nursery + * buffer may hold before an append would bring the buffered total to the + * trigger or would need the buffer to grow. While a pass runs, every + * release takes the call. Recomputed wherever the buffers, the nursery + * buffer's capacity or the trigger change in bulk; the release and death + * paths adjust it by one (scr_cyc_window_shrink). */ +static inline void scr_cyc_window_update(void) { +#if SCR_CYC_INLINE_BUFFERING + size_t window = 0; + if (!scr_collecting) { + size_t older = scr_roots[SCR_CYC_MATURE].n + scr_roots[SCR_CYC_OLD].n; + if (scr_cyc_trigger > older + 1) window = scr_cyc_trigger - older - 1; + if (window > scr_roots[SCR_CYC_NURSERY].cap) window = scr_roots[SCR_CYC_NURSERY].cap; + } + scr_cyc_buffers.window = window; +#endif +} + +/* One step of the window's formula going down by one: an older candidate + * was buffered, or a nursery death lowered the trigger. A window below the + * formula only sends more releases through the call, so the clamp to the + * capacity can stay where it is. */ +static inline void scr_cyc_window_shrink(void) { +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_buffers.window) scr_cyc_buffers.window--; +#endif +} + static bool scr_cyc_older_backlog(void) { return scr_roots[SCR_CYC_MATURE].n != 0 || scr_roots[SCR_CYC_OLD].n != 0; } @@ -682,6 +715,7 @@ static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_grow * per pass, so it is kept out of line and built for size, which leaves the * hot buffering path in scr_cyc_on_release small. */ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { + scr_cyc_route_bypassed(); unsigned gen = SCR_CYC_OLD; bool by_growth = scr_cyc_grown(SCR_CYC_OLD); if (!by_growth && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { @@ -699,6 +733,7 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { * per turn would walk the whole live heap every turn — which is exactly the * cost the generations exist to avoid. */ void scr_cyc_collect_scheduled(void) { + scr_cyc_route_bypassed(); if (scr_cyc_buffered() == 0) { scr_cyc_scheduled_mature_age = 0; return; @@ -778,6 +813,10 @@ typedef struct ScrCycKind { static SCR_TL ScrCycKind *scr_cyc_kinds; static SCR_TL size_t scr_cyc_kinds_cap; #endif +#if SCR_CYC_INLINE_BUFFERING +/* Types that bypass the nursery right now (scr_cyc_route_bypassed). */ +static size_t scr_cyc_nursery_bypassing = 0; +#endif /* The statistics of `kind` (nonzero), growing the table to cover it. */ static SCR_CYC_COMPACT ScrCycKind *scr_cyc_kind_grow(uint32_t kind) { @@ -802,7 +841,12 @@ static inline bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { if (kind >= scr_cyc_kinds_cap) return false; ScrCycLevelStat *k = &scr_cyc_kinds[kind].level[gen]; if (k->bypass == 0) return false; - if (--k->bypass == 0) k->walked = k->freed = 0; + if (--k->bypass == 0) { + k->walked = k->freed = 0; +#if SCR_CYC_INLINE_BUFFERING + if (gen == SCR_CYC_NURSERY) scr_cyc_nursery_bypassing--; +#endif + } return true; } @@ -820,9 +864,16 @@ static inline void scr_cyc_walked(const ScrCycType *type, unsigned gen) { if (!type->kind) return; ScrCycLevelStat *k = &scr_cyc_kind(type->kind)->level[gen]; if (++k->walked == SCR_CYC_BYPASS_WINDOW) { +#if SCR_CYC_INLINE_BUFFERING + bool before = k->bypass != 0; +#endif k->bypass = k->freed * SCR_CYC_BYPASS_RATIO < SCR_CYC_BYPASS_WINDOW ? SCR_CYC_BYPASS_WINDOW * SCR_CYC_BYPASS_PROBE : 0; +#if SCR_CYC_INLINE_BUFFERING + if (gen == SCR_CYC_NURSERY && before != (k->bypass != 0)) + scr_cyc_nursery_bypassing += before ? (size_t)-1 : 1; +#endif k->walked = k->freed = 0; } } @@ -831,6 +882,40 @@ static inline void scr_cyc_freed(const ScrCycType *type, unsigned gen) { if (type->kind) scr_cyc_kind(type->kind)->level[gen].freed++; } +/* An inline append (scr_rc.h) leaves every nursery candidate in the nursery + * buffer, including one whose type bypasses the nursery, which + * scr_cyc_on_release would have buffered past it. Where a candidate waits + * matters only once something reads the buffers, so every scheduling + * decision and every pass first moves such candidates to the generation + * scr_cyc_on_release would have chosen, consuming the bypass counts as it + * would have, in buffer order. Candidates that scr_cyc_on_release buffered + * already passed the same test with the counts unchanged since (they only + * change during passes), so they stay. */ +static SCR_CYC_COMPACT void scr_cyc_route_bypassed(void) { +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_nursery_bypassing == 0) return; + ScrVec *nursery = &scr_roots[SCR_CYC_NURSERY]; + for (size_t i = 0; i < nursery->n;) { + void *obj = nursery->v[i]; + ScrCycHdr *h = scr_cyc_hdr(obj); + unsigned gen = scr_cyc_buffer_gen(scr_cyc_type(h), SCR_CYC_NURSERY); + if (gen == SCR_CYC_NURSERY) { + i++; + continue; + } + void *last = nursery->v[--nursery->n]; + nursery->v[i] = last; + if (last != obj) scr_cyc_set_buffered(scr_cyc_hdr(last), (uint32_t)i); + scr_cyc_set_gen(h, gen); + ScrVec *b = &scr_roots[gen]; + if (b->n == b->cap) scr_cyc_grow(b); + scr_cyc_set_buffered(h, (uint32_t)b->n); + b->v[b->n++] = obj; + } + scr_cyc_window_update(); +#endif +} + void scr_cyc_on_dead(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); if (!scr_cyc_is_buffered(h)) return; @@ -843,11 +928,12 @@ void scr_cyc_on_dead(void *obj) { b->v[i] = last; if (last != obj) scr_cyc_set_buffered(scr_cyc_hdr(last), (uint32_t)i); scr_cyc_clear_buffered(h); - scr_cyc_nbuffered--; /* A death must not consume room reserved for a future candidate. */ scr_cyc_trigger--; - if (scr_cyc_gen(h) != SCR_CYC_NURSERY && !scr_cyc_older_backlog()) - scr_cyc_scheduled_mature_age = 0; + /* An older death lowers the older count with the trigger: the window + * stays. A nursery death lowers only the trigger. */ + if (scr_cyc_gen(h) == SCR_CYC_NURSERY) scr_cyc_window_shrink(); + else if (!scr_cyc_older_backlog()) scr_cyc_scheduled_mature_age = 0; } /* Publication (scr_publish.c) takes buffered objects out of their candidate @@ -896,31 +982,39 @@ void scr_cyc_pub_fill(void) { } holes->n = 0; size_t closed = before - b->n; - scr_cyc_nbuffered -= closed; /* A death must not consume room reserved for a future candidate. */ scr_cyc_trigger -= closed; if (closed && gen != SCR_CYC_NURSERY) older = true; } if (older && !scr_cyc_older_backlog()) scr_cyc_scheduled_mature_age = 0; + scr_cyc_window_update(); } /* Every release that leaves an unbuffered object alive lands here (the - * emitted and inline release paths test `buffered` first), so it stays what - * it has always been: buffer inline, then one compare. */ + * emitted and inline release paths test `buffered` first) unless the window + * let it append a nursery candidate on its own fast path (scr_rc.h), so it + * stays what it has always been: the same append, then buffer and compare. */ void scr_cyc_on_release(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); /* Releasing an existing candidate cannot advance the trigger. A pass * always re-arms after its teardowns, including any candidates they add. */ if (scr_cyc_is_buffered(h)) return; +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_append(h, obj)) return; +#endif if (scr_cyc_gen(h) < SCR_CYC_OLD) scr_cyc_set_gen(h, scr_cyc_buffer_gen(scr_cyc_type(h), scr_cyc_gen(h))); - ScrVec *b = &scr_roots[scr_cyc_gen(h)]; - if (b->n == b->cap) scr_cyc_grow(b); + unsigned gen = scr_cyc_gen(h); + ScrVec *b = &scr_roots[gen]; + if (b->n == b->cap) { + scr_cyc_grow(b); + scr_cyc_window_update(); + } scr_cyc_set_buffered(h, (uint32_t)b->n); b->v[b->n++] = obj; - scr_cyc_nbuffered++; + if (gen != SCR_CYC_NURSERY) scr_cyc_window_shrink(); /* Never re-entered: a teardown's releases of untraced children can buffer * new candidates mid-collection, but they only wait for the next pass. */ - if (!scr_collecting && (scr_cyc_nbuffered >= scr_cyc_trigger || scr_cyc_live >= scr_cyc_alloc_trigger)) + if (!scr_collecting && (scr_cyc_buffered() >= scr_cyc_trigger || scr_cyc_live >= scr_cyc_alloc_trigger)) scr_cyc_collect_due(); } @@ -1272,10 +1366,10 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit, bool doomed) { } /* markRoots drained buffers in bulk and teardowns moved the count around; * resync from the buffers themselves and re-arm. */ - scr_cyc_nbuffered = scr_cyc_buffered(); - scr_cyc_trigger = scr_cyc_nbuffered + scr_cyc_nursery_threshold(); + scr_cyc_trigger = scr_cyc_buffered() + scr_cyc_nursery_threshold(); scr_cyc_alloc_trigger = scr_cyc_live + SCR_CYC_NURSERY_ALLOCATIONS; scr_collecting = false; + scr_cyc_window_update(); return freed; } @@ -1315,7 +1409,9 @@ static size_t scr_cyc_pass_done(size_t freed) { static size_t scr_cyc_pass(unsigned gen_limit) { if (scr_collecting) return 0; + scr_cyc_route_bypassed(); scr_collecting = true; + scr_cyc_window_update(); scr_gen_limit = gen_limit; /* markRoots: every buffered entry is a candidate (retains do not mark @@ -1488,6 +1584,9 @@ static size_t scr_cyc_pass(unsigned gen_limit) { #ifdef SCR_CYC_TESTING /* Tests only: one pass at exactly `gen`, outside the schedule. */ size_t scr_cyc_pass_at(unsigned gen) { return scr_cyc_pass(gen); } +/* Tests only: route appended candidates of bypassing types, as the next + * scheduling decision or pass would. */ +void scr_cyc_route_bypassed_at(void) { scr_cyc_route_bypassed(); } #endif size_t scr_cyc_collect_set(const ScrCycSet *set, unsigned gen) { @@ -1526,4 +1625,8 @@ void scr_cyc_context_cleanup(void) { free(scr_cyc_kinds); scr_cyc_kinds = NULL; scr_cyc_kinds_cap = 0; +#if SCR_CYC_INLINE_BUFFERING + scr_cyc_nursery_bypassing = 0; +#endif + scr_cyc_window_update(); } diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index d5d83a72a..b4c61a6ff 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -463,12 +463,76 @@ uint32_t scr_cyc_pub_take(void *obj); void scr_cyc_pub_restore(void *obj, uint32_t index); void scr_cyc_pub_fill(void); +/* The candidate buffers, one per generation (scr_cycle.c), and the window + * through which a release buffers a nursery candidate on its fast path. A + * release that leaves an unbuffered nursery object alive appends it to the + * nursery buffer while that buffer holds fewer than `window` entries and the + * live count is below `alloc_trigger` (scr_cyc_append); anything else takes + * the full scr_cyc_on_release. scr_cycle.c keeps `window` at most at the + * number of nursery entries the buffers can hold before a collection falls + * due or the buffer must grow, and at 0 while a pass runs, so the append is + * exactly what scr_cyc_on_release would do with a nursery candidate. A + * candidate whose type bypasses the nursery is moved past it before the + * buffers are next read (scr_cycle.c, "level bypass"). + * + * The runtime's own release paths append inline (scr_cyc_released), and + * scr_cyc_on_release tries the append first. The layout is an ABI: + * optimized emitted code appends through a per-module helper + * (llvm/alloc.ts cycleAppendHelper) that addresses roots[0].v, roots[0].n, + * window and alloc_trigger at fixed offsets. Builds whose collector state + * is thread-local (worker executables, thread-instanced libraries) never + * append on a fast path. */ +typedef struct ScrCycBuffer { + void **v; + size_t n, cap; + /* The most entries a pass held, for the work vectors a pass fills + * (scr_cycle.c ScrVec); unused by the candidate buffers. */ + size_t used; +} ScrCycBuffer; +typedef struct ScrCycBuffers { + ScrCycBuffer roots[SCR_CYC_NGENS]; + size_t window; + size_t alloc_trigger; +} ScrCycBuffers; +#if !defined(SCR_WORKERS) && !(defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)) +#define SCR_CYC_INLINE_BUFFERING 1 +extern ScrCycBuffers scr_cyc_buffers; +#if UINTPTR_MAX == UINT64_MAX +_Static_assert(offsetof(ScrCycBuffers, roots) == 0 && offsetof(ScrCycBuffer, v) == 0 && + offsetof(ScrCycBuffer, n) == 8 && offsetof(ScrCycBuffers, window) == 96 && + offsetof(ScrCycBuffers, alloc_trigger) == 104, + "llvm/alloc.ts appends nursery candidates against this layout"); +#endif +/* Append the unbuffered object `obj` (header `h`) to the nursery buffer if + * it is a nursery object and the window allows; false leaves it to the rest + * of scr_cyc_on_release. */ +static inline bool scr_cyc_append(ScrCycHdr *h, void *obj) { + ScrCycBuffer *nursery = &scr_cyc_buffers.roots[SCR_CYC_NURSERY]; + size_t n = nursery->n; + if (scr_cyc_gen(h) != SCR_CYC_NURSERY || n >= scr_cyc_buffers.window || + scr_cyc_live >= scr_cyc_buffers.alloc_trigger) + return false; + nursery->v[n] = obj; + nursery->n = n + 1; + scr_cyc_set_buffered(h, (uint32_t)n); + return true; +} +#else +#define SCR_CYC_INLINE_BUFFERING 0 +#endif + /* Inline halves of the hooks for runtime release paths, mirroring the * emitted ones (llvm/shapes.ts): a release of an already-buffered - * object, and the death of an object that was never buffered, need no call. + * object, and the death of an object that was never buffered, need no call, + * and a nursery candidate is appended inline while the window allows. * Most releases of long-lived containers take the first early return. */ static inline void scr_cyc_released(void *obj) { - if (!scr_cyc_is_buffered(scr_cyc_hdr(obj))) scr_cyc_on_release(obj); + ScrCycHdr *h = scr_cyc_hdr(obj); + if (scr_cyc_is_buffered(h)) return; +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_append(h, obj)) return; +#endif + scr_cyc_on_release(obj); } static inline void scr_cyc_dead(void *obj) { if (scr_cyc_is_buffered(scr_cyc_hdr(obj))) scr_cyc_on_dead(obj); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index ba0bc0819..c50f0d5c5 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -712,8 +712,11 @@ static void check_deferred_white_restoration(void) { * once the type's objects die in nursery cycles again, the next probe window * finds that and it is walked again. Window, ratio and probe interval mirror * SCR_CYC_BYPASS_*. */ -/* Tests only (SCR_CYC_TESTING): one pass at exactly `gen`. */ +/* Tests only (SCR_CYC_TESTING): one pass at exactly `gen`, and the routing + * of appended candidates whose type bypasses a level, which the next + * scheduling decision or pass would do. */ size_t scr_cyc_pass_at(unsigned gen); +void scr_cyc_route_bypassed_at(void); static size_t bypass_traced; /* trace calls on bypass-kind nodes */ static size_t bypass_freed; /* bypass-kind nodes freed */ static void bypass_trace(void *obj, ScrTraceVisit visit, void *ctx) { @@ -785,6 +788,22 @@ static void check_nursery_bypass(void) { scr_collect_cycles(); check(bypass_freed - before == 2 * RINGS, "an older pass missed dead rings of a bypassed type"); + /* The same through the inline release path, which leaves the candidates + * in the nursery buffer: the next pass routes them past the nursery. */ + before = bypass_freed; + for (size_t r = 0; r < RINGS; r++) { + Node *a = make_bypass_node(), *b = make_bypass_node(); + a->next = b; + b->next = a; + a->rc++; + a->rc--; + scr_cyc_released(a); + } + run_release_passes(); + check(bypass_freed - before < 2 * RINGS, "inline-buffered bypassed rings were walked by the nursery"); + scr_collect_cycles(); + check(bypass_freed - before == 2 * RINGS, "an older pass missed inline-buffered rings"); + /* Now the type's objects die in nursery cycles. Once INTERVAL windows' * worth of candidates went past the nursery, a probe window walks them * there again, and its yield turns the bypass off. A nursery pass after @@ -832,13 +851,16 @@ static Node *make_mature_kind_node(void) { } /* How many of PROBE fresh, externally owned candidates a release buffers - * straight into the old generation. */ + * straight into the old generation, by the time anything reads the buffers + * (a release may append the candidate to the nursery buffer, which the next + * decision routes past the levels its type bypasses). */ static size_t probe_old_on_release(Node **owned, size_t probe) { scr_collect_cycles(); /* reset the growth baselines: no pass runs below */ size_t old = 0; for (size_t i = 0; i < probe; i++) { owned[i] = make_mature_kind_node(); read_live(owned[i]); + scr_cyc_route_bypassed_at(); old += scr_cyc_gen(scr_cyc_hdr(owned[i])) == SCR_CYC_OLD; } return old; @@ -1187,7 +1209,55 @@ static void check_large_old_buffer_with_nursery_candidates(void) { } } +/* Releases through scr_cyc_released append nursery candidates inline while + * the window allows, and fall back to scr_cyc_on_release exactly when a + * collection is due: the candidate that brings the buffered total to the + * trigger runs the pass, as it would through the call. */ +static void check_inline_buffering(void) { + enum { MAX = 4096 }; + static Node *nodes[MAX]; + size_t threshold = configured_nursery_threshold(); + if (threshold > MAX) return; + scr_collect_cycles(); + size_t before = traced; + for (size_t i = 0; i < threshold; i++) { + nodes[i] = make_ring(1); + nodes[i]->rc--; /* drop the self edge: a live leaf held by its owner */ + nodes[i]->next = NULL; + nodes[i]->rc++; + nodes[i]->rc--; + scr_cyc_released(nodes[i]); + ScrCycHdr *h = scr_cyc_hdr(nodes[i]); + if (i + 1 < threshold) { + check(scr_cyc_is_buffered(h) && scr_cyc_gen(h) == SCR_CYC_NURSERY, + "a nursery release did not buffer its candidate"); +#if SCR_CYC_INLINE_BUFFERING + check(scr_cyc_buffers.roots[SCR_CYC_NURSERY].v[scr_cyc_buf_index(h)] == nodes[i], + "an inline append is not where the header says"); +#endif + check(traced == before, "a pass ran before the trigger"); + } + } + /* The last release reached the trigger: one nursery pass walked them all + * and promoted the survivors, which wait in the mature buffer. */ + check(traced > before, "the release that reached the trigger did not collect"); + for (size_t i = 0; i < threshold; i++) { + ScrCycHdr *h = scr_cyc_hdr(nodes[i]); + check(scr_cyc_gen(h) == SCR_CYC_MATURE, "a nursery survivor was not promoted"); + check(nodes[i]->rc == 1, "a survivor lost its count"); + } + size_t freed_before = freed; + for (size_t i = 0; i < threshold; i++) { + nodes[i]->rc = 0; + scr_cyc_on_dead(nodes[i]); + node_free(nodes[i]); + } + check(freed - freed_before == threshold, "the leaves were not freed"); + scr_collect_cycles(); +} + int main(void) { + check_inline_buffering(); check_old_garbage_reclaimed_while_growing(); check_older_rings_into_old(); check_cycle_spanning_mature_and_old(); From 5a574e86ce5196d26f9eaed772f26d7f4b971c79 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 16:46:10 -0500 Subject: [PATCH 06/12] Raise the static hello size ceiling to 96 KiB on Linux The tree-shaking test failed on Linux x64 after main was merged into this branch: "expected 84496 to be less than 81920". Measured with the release runtime pack (hello = console.log("hello", "world")): tree file R+E segment main b866ae323 80288 0x5fe9 989d0d6bc 79928 0x5fe9 merge 474012f04 84488 0x6579 No runtime family comes back: hello's absence checks still pass. The code that grows is the cycle collector and the release paths that feed it, which hello's exit reaches (scr_lib_init -> scr_lib_cleanup -> scr_arr_release -> scr_cyc_on_release -> scr_cyc_collect_due -> the pass). Main grew it by 1,447 bytes (the death rule and scr_mem_order_for_free), this branch by 1,386 (the inline nursery appends in the runtime's release functions, scr_cyc_route_bypassed and the window). Main left .text 23 bytes under the executable segment's 24 KiB page, so together they cross it, and every later segment and the file move by 4 KiB. 96 KiB, the Mach-O ceiling, restores several pages of slack. --- tests/harness/runtime-tree-shaking.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/harness/runtime-tree-shaking.test.ts b/tests/harness/runtime-tree-shaking.test.ts index b0d09545f..7d85a48c3 100644 --- a/tests/harness/runtime-tree-shaking.test.ts +++ b/tests/harness/runtime-tree-shaking.test.ts @@ -160,12 +160,14 @@ nativeToolchainTest( // Symbol absence is the primary reachability contract. Keep a deliberately // roomy, platform-specific hello-world ceiling too: it catches losing // section GC without pinning an exact linker/SDK byte count. The canonical - // Linux build is about 64-68KB and current Mach-O builds are about 70KB. - // ELF segments are page aligned, so the Linux size moves in 4 KiB steps - // whenever .text crosses a page boundary. These limits leave several - // native pages of linker-version slack while remaining far below the - // former roughly-400KB always-linked runtime. - const helloSizeLimit = process.platform === "linux" ? 80 * 1024 : 96 * 1024; + // Linux build is about 84KB and current Mach-O builds are about 70KB. Most + // of Linux hello's runtime code is the cycle collector, which its exit + // path reaches (scr_lib_cleanup releases an array). ELF segments are page + // aligned, so the Linux size moves in 4 KiB steps whenever .text crosses + // a page boundary. These limits leave several native pages of + // linker-version slack while remaining far below the former roughly-400KB + // always-linked runtime. + const helloSizeLimit = 96 * 1024; expect(statSync(hello.binaryPath).size).toBeLessThan(helloSizeLimit); }, ); From b119b4cf0352fb95e173c14d9f66f50b9845eaff Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 17:32:17 -0500 Subject: [PATCH 07/12] Stringify class instances through the runtime JSON traversal JSON.stringify of a class instance, or of a record, array or union that holds one, boxes the value and serializes it with the runtime JSON traversal. The output matches Node: own enumerable properties in creation order, properties added after construction, toJSON on the instance or a base class, and no methods, accessors, #private fields or static fields. A class whose declared field cannot be read through the checked-dynamic property view, such as a Date field, is refused at compile time with SC1090 naming the field. Boxed Map and Set values now stringify as {}, as Node does, instead of throwing. --- .../language-features/class-reflection.mdx | 2 +- .../src/frontend/lowering/builtins/json.ts | 70 ++++++++++ packages/compiler/src/ir/ir.test.ts | 11 ++ packages/compiler/src/ir/ir.ts | 15 +- ...000-json-stringify-class-instances.ts.json | 6 + ...-json-stringify-js-class-instances.js.json | 6 + .../diagnostics/json-class-fields.ts.json | 6 + packages/runtime/src/scr_json.c | 22 ++- .../6000-json-stringify-class-instances.ts | 131 ++++++++++++++++++ .../6001-json-stringify-js-class-instances.js | 49 +++++++ tests/diagnostics/json-class-fields.ts | 15 ++ .../__snapshots__/json-class-fields.ts.txt | 13 ++ tests/harness/corpus-costs.json | 4 + 13 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json create mode 100644 tests/corpus/6000-json-stringify-class-instances.ts create mode 100644 tests/corpus/6001-json-stringify-js-class-instances.js create mode 100644 tests/diagnostics/json-class-fields.ts create mode 100644 tests/harness/__snapshots__/json-class-fields.ts.txt diff --git a/docs/content/limitations/language-features/class-reflection.mdx b/docs/content/limitations/language-features/class-reflection.mdx index 4e2b9f299..8b0738cc7 100644 --- a/docs/content/limitations/language-features/class-reflection.mdx +++ b/docs/content/limitations/language-features/class-reflection.mdx @@ -1 +1 @@ -- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. They raise an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. +- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Map and Set fields as `{}`, as Node.js does, and refuses classes with Date fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index a73c9d5e7..c99a99c75 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -8,6 +8,10 @@ import { isJsonStringifyDynamicType, BOOL, DYN, + type IrType, + classDynViewSupported, + isClassOwnEnumerableFieldName, + isDynTypedRefType, type IrExpr, STRING, type SrcLoc, @@ -157,6 +161,16 @@ export function lowerJsonMethodCall( (id) => lowerer.unions.get(id), ) ) { + const unreadable = classFieldWithoutJsonView(lowerer, value.type, new Set()); + if (unreadable !== null) { + lowerer.unsupported( + "SC1090", + argNode, + `JSON.stringify of '${lowerer.fmt(value.type)}' values (field '${unreadable.field}' of class ` + + `'${unreadable.className}' has type '${lowerer.fmt(unreadable.type)}', which the runtime JSON ` + + `traversal cannot read)`, + ); + } // Keep the original containers live while the runtime visits them: // a toJSON callback can mutate a later field, and shared/cyclic // references must retain their identities throughout traversal. @@ -276,6 +290,62 @@ function lowerOptionalStringifyRoot( return { kind: "call", callee: helper, args: [value], type: resultT, loc }; } +/** The first class field reachable from a stringify input that the class's + * checked-dynamic property view cannot represent. The runtime JSON traversal + * reads class instances through that view, so such a class is refused at + * compile time instead of throwing when the view is built. */ +function classFieldWithoutJsonView( + lowerer: Lowerer, + t: IrType, + visiting: Set, +): { className: string; field: string; type: IrType } | null { + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + switch (t.kind) { + case "array": + return classFieldWithoutJsonView(lowerer, t.elem, visiting); + case "record": { + if (visiting.has(t.shapeId)) return null; + visiting.add(t.shapeId); + const shape = getRecord(t.shapeId); + for (const field of shape?.fields ?? []) { + const found = classFieldWithoutJsonView(lowerer, field.type, visiting); + if (found) return found; + } + return shape?.indexValue + ? classFieldWithoutJsonView(lowerer, shape.indexValue, visiting) + : null; + } + case "union": { + if (visiting.has(t.unionId)) return null; + visiting.add(t.unionId); + for (const arm of getUnion(t.unionId)?.arms ?? []) { + const found = classFieldWithoutJsonView(lowerer, arm, visiting); + if (found) return found; + } + return null; + } + case "object": { + if (!isDynTypedRefType(t) || visiting.has(t.className)) return null; + visiting.add(t.className); + const info = lowerer.classes.get(t.className); + for (const field of info?.def.fields ?? []) { + if (!isClassOwnEnumerableFieldName(field.name)) continue; + if (!classDynViewSupported([field], getRecord, getUnion)) { + const className = + info?.def.jsName ?? info?.decl?.name?.text ?? t.className.replace(/^%/, ""); + return { className, field: field.name, type: field.type }; + } + const found = classFieldWithoutJsonView(lowerer, field.type, visiting); + if (found) return found; + } + return null; + } + default: + return null; + } +} + /** Only explicit null/undefined select the no-callback path. */ function jsonNullishArgument(lowerer: Lowerer, node: ts.Expression): boolean { if (ts.isParenthesizedExpression(node)) return jsonNullishArgument(lowerer, node.expression); diff --git a/packages/compiler/src/ir/ir.test.ts b/packages/compiler/src/ir/ir.test.ts index 60ecc9ad0..52ef9b9c1 100644 --- a/packages/compiler/src/ir/ir.test.ts +++ b/packages/compiler/src/ir/ir.test.ts @@ -159,6 +159,17 @@ describe("checked records with opaque payloads", () => { isJsonStringifyDynamicType(arrayOf({ kind: "union", unionId: "optional" }), record, union), ).toBe(true); }); + + test("class instances stringify through the runtime JSON traversal only", () => { + const point: IrType = { kind: "object", className: "Point" }; + const failure: IrType = { kind: "object", className: "%Error" }; + expect(isJsonStringifyDynamicType(point, record, union)).toBe(true); + expect(isJsonStringifyDynamicType(arrayOf(point), record, union)).toBe(true); + expect(isJsonStringifyDynamicType(failure, record, union)).toBe(false); + expect(isJsonStringifySafeType(point, record, union)).toBe(false); + expect(isJsonSafeType(point, record, union)).toBe(false); + expect(canDynCheckTo(arrayOf(point), record, union)).toBe(true); + }); }); describe("native typed-array boundaries", () => { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 68a4776f5..8c5299a23 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3717,9 +3717,10 @@ export function isJsonStringifySafeType( return isJsonSafeAt(t, getRecord, getUnion, true, false, new Set()); } -/** A JSON-shaped container with unknown payloads needs the runtime JSON - * traversal. Its declared structure can be boxed, but payloads may carry - * omitted values, toJSON methods, or cycles. This predicate does not widen +/** A JSON-shaped container with unknown payloads, or a class instance at + * any position, needs the runtime JSON traversal. Its declared structure can + * be boxed, but payloads may carry omitted values, toJSON methods, added + * properties, or cycles. This predicate does not widen * the type-directed serializer, checked-value boundary, or root-undefined * contract; callers must select the checked runtime traversal explicitly. */ export function isJsonStringifyDynamicType( @@ -3728,7 +3729,7 @@ export function isJsonStringifyDynamicType( getUnion: (unionId: string) => IrUnionDef | undefined, ): boolean { return ( - (t.kind === "record" || t.kind === "array" || t.kind === "union") && + (t.kind === "record" || t.kind === "array" || t.kind === "union" || isDynTypedRefType(t)) && isJsonSafeAt(t, getRecord, getUnion, true, false, new Set(), true) && !isJsonStringifySafeType(t, getRecord, getUnion) ); @@ -3838,8 +3839,12 @@ function isJsonSafeAt( return nativeFields && !stringify; case "bytes": return nativeFields; - case "func": + // Class instances stringify through the runtime JSON traversal, which + // reads their own enumerable properties and calls toJSON. case "object": + if (nativeFields && stringify && isDynTypedRefType(t)) return true; + return false; + case "func": // Class values stringify as "{}" husks in Node (own enumerable statics // aside — not representable type-directedly); rejected like Maps. case "classval": diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json new file mode 100644 index 000000000..93c106c07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6000-json-stringify-class-instances.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json new file mode 100644 index 000000000..8ecb709f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6001-json-stringify-js-class-instances.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json new file mode 100644 index 000000000..ee3ed6bad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/json-class-fields.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b96a19757..0c45d958e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -12818,8 +12818,15 @@ static ScrDyn *scr_native_set_pack(void *ptr) { ScrDyn *scr_dyn_native_set(ScrMap *value) { static const ScrDynHandleOps ops = { - "Set", &scr_map_retain_v, &scr_map_release_v, &scr_native_set_invoke, - &scr_native_set_get, &scr_weak_map_write, NULL, &scr_native_set_pack, + .cls = "Set", + .retain = &scr_map_retain_v, + .release = &scr_map_release_v, + .invoke = &scr_native_set_invoke, + .get = &scr_native_set_get, + .set = &scr_weak_map_write, + .iter_pack = &scr_native_set_pack, + /* Entries live in internal slots: JSON.stringify writes {}, as Node. */ + .json_empty_object = true, }; scr_dyn_handle_install(SCR_DYNH_SET, &ops); return scr_dyn_new_handle(value, SCR_DYNH_SET); @@ -12928,8 +12935,15 @@ static ScrDyn *scr_native_map_pack(void *ptr) { ScrDyn *scr_dyn_native_map(ScrMap *value) { static const ScrDynHandleOps ops = { - "Map", &scr_map_retain_v, &scr_map_release_v, &scr_native_map_invoke, - &scr_native_map_get, &scr_weak_map_write, NULL, &scr_native_map_pack, + .cls = "Map", + .retain = &scr_map_retain_v, + .release = &scr_map_release_v, + .invoke = &scr_native_map_invoke, + .get = &scr_native_map_get, + .set = &scr_weak_map_write, + .iter_pack = &scr_native_map_pack, + /* Entries live in internal slots: JSON.stringify writes {}, as Node. */ + .json_empty_object = true, }; scr_dyn_handle_install(SCR_DYNH_MAP, &ops); return scr_dyn_new_handle(value, SCR_DYNH_MAP); diff --git a/tests/corpus/6000-json-stringify-class-instances.ts b/tests/corpus/6000-json-stringify-class-instances.ts new file mode 100644 index 000000000..a73168b40 --- /dev/null +++ b/tests/corpus/6000-json-stringify-class-instances.ts @@ -0,0 +1,131 @@ +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + norm(): number { + return Math.hypot(this.x, this.y); + } +} + +class Account { + static count = 0; + #secret = "hidden"; + private balance = 10; + protected owner = "ada"; + label: string | undefined = undefined; + tags: string[] = ["a", "b"]; + get secret(): string { + return this.#secret; + } +} + +class Stamp { + at = 5; + toJSON(key: string): string { + return `stamp(${key})`; + } +} + +class Base { + kind = "base"; + toJSON(): { kind: string; via: string } { + return { kind: this.kind, via: "Base.toJSON" }; + } +} +class Child extends Base { + extra = 1; +} + +class Labeled extends Point { + name = "p"; +} + +class Box { + value: T; + constructor(value: T) { + this.value = value; + } +} + +class Registry { + entries = new Map([["a", 1]]); + count = 2; +} + +class Cycle { + name = "loop"; + next: Cycle | null = null; +} + +class Big { + n = 10n; +} + +const tag = Symbol("tag"); + +class Handlers { + name = "h"; + onEvent = (n: number): number => n + 1; + payload: unknown = { a: [1, "two", null] }; + [tag] = "symbol-keyed"; +} + +class Failure extends Error { + code = "E_FAIL"; +} + +class Empty {} + +class Tagged { + s = new Set([1, 2]); + count = 2; +} + +const p = new Point(3, 4); +console.log(JSON.stringify(p)); +console.log(JSON.stringify(new Account())); +console.log(JSON.stringify(new Stamp())); +console.log(JSON.stringify({ first: new Stamp(), list: [new Stamp()] })); +console.log(JSON.stringify(new Child())); +console.log(JSON.stringify(new Labeled(1, 2))); +console.log(JSON.stringify(new Box("text")), JSON.stringify(new Box(new Point(0, 1)))); +console.log(JSON.stringify(new Registry())); +console.log(JSON.stringify([p, null, new Point(5, 6)])); +console.log(JSON.stringify({ point: p, points: [p, p] }, null, 2)); +console.log(JSON.stringify(p, null, "--")); +console.log(JSON.stringify(p, ["x"])); +console.log(JSON.stringify(p, (key: string, value: unknown) => (key === "y" ? undefined : value))); + +const maybe: Point | null = Math.random() < 2 ? p : null; +console.log(JSON.stringify(maybe)); + +const loop = new Cycle(); +loop.next = loop; +try { + JSON.stringify(loop); + console.log("no error"); +} catch (e) { + console.log(e instanceof TypeError); +} + +try { + JSON.stringify(new Big()); + console.log("no error"); +} catch (e) { + console.log(e instanceof TypeError); +} + +p.x = 30; +console.log(JSON.stringify(p)); + +const h = new Handlers(); +console.log(JSON.stringify(h)); +console.log(JSON.stringify(new Empty())); +console.log(JSON.stringify(new Failure("boom"))); +h.payload = 7; +console.log(JSON.stringify({ handlers: [h] })); +const u: unknown = new Map([["a", 1]]); +console.log(JSON.stringify(new Tagged()), JSON.stringify(u)); diff --git a/tests/corpus/6001-json-stringify-js-class-instances.js b/tests/corpus/6001-json-stringify-js-class-instances.js new file mode 100644 index 000000000..d270a632f --- /dev/null +++ b/tests/corpus/6001-json-stringify-js-class-instances.js @@ -0,0 +1,49 @@ +class Box { + constructor(v) { + this.v = v; + } +} + +class Plain {} + +class Late { + constructor() { + this.a = 1; + } + setB() { + this.b = 2; + } + toString() { + return "late"; + } +} + +class Shaped { + constructor(n) { + this.n = n; + } + toJSON() { + return { shaped: this.n }; + } +} + +const b = new Box(1); +console.log(JSON.stringify(b)); +b.extra = "e"; +console.log(JSON.stringify(b)); + +const q = new Plain(); +console.log(JSON.stringify(q)); +q.a = 1; +q.inner = new Box(2); +q.list = [new Box(3), null]; +console.log(JSON.stringify(q)); +console.log(JSON.stringify([q, b], null, 1)); + +const late = new Late(); +console.log(JSON.stringify(late)); +late.c = 3; +late.setB(); +console.log(JSON.stringify(late)); + +console.log(JSON.stringify({ s: new Shaped(4), arr: [new Shaped(5)] })); diff --git a/tests/diagnostics/json-class-fields.ts b/tests/diagnostics/json-class-fields.ts new file mode 100644 index 000000000..81012c161 --- /dev/null +++ b/tests/diagnostics/json-class-fields.ts @@ -0,0 +1,15 @@ +// JSON.stringify reads class instances through their checked-dynamic +// property view. A class whose declared field has no representation in +// that view is refused at compile time, naming the field. +class Schedule { + name = "weekly"; + next = new Date(Date.UTC(2024, 0, 1)); +} + +class Calendar { + title = "team"; + schedules: Schedule[] = [new Schedule()]; +} + +console.log(JSON.stringify(new Schedule())); +console.log(JSON.stringify({ calendar: new Calendar() })); diff --git a/tests/harness/__snapshots__/json-class-fields.ts.txt b/tests/harness/__snapshots__/json-class-fields.ts.txt new file mode 100644 index 000000000..568cfa47f --- /dev/null +++ b/tests/harness/__snapshots__/json-class-fields.ts.txt @@ -0,0 +1,13 @@ +json-class-fields.ts:14:28 - error SC1090: JSON.stringify of 'Schedule' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet + + 13 | + 14 | console.log(JSON.stringify(new Schedule())); + | ^~~~~~~~~~~~~~ + 15 | console.log(JSON.stringify({ calendar: new Calendar() })); + +json-class-fields.ts:15:28 - error SC1090: JSON.stringify of '{ calendar: Calendar }' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet + + 14 | console.log(JSON.stringify(new Schedule())); + 15 | console.log(JSON.stringify({ calendar: new Calendar() })); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 16 | \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 18147374d..2dd8a90db 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2201,6 +2201,8 @@ "5995-array-dense-loop-boundaries.ts": 2596, "5996-rejections-between-macrotasks.cjs": 3034, "600-closures-basic.ts": 2349, + "6000-json-stringify-class-instances.ts": 7567, + "6001-json-stringify-js-class-instances.js": 4668, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, "6020-class-extends-function-constructor.js": 4300, @@ -5241,6 +5243,8 @@ "5995-array-dense-loop-boundaries.ts": 4171, "5996-rejections-between-macrotasks.cjs": 22172, "600-closures-basic.ts": 2534, + "6000-json-stringify-class-instances.ts": 12107, + "6001-json-stringify-js-class-instances.js": 7469, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, "6020-class-extends-function-constructor.js": 7500, From f4a8511dca71301bca190291caa0c742046401b1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:58:57 -0700 Subject: [PATCH 08/12] Compile maybe-promise unions, unknown[] rest parameters, and function-valued Maps and Sets A union may now hold one promise beside data members (`T | Promise`, `Promise | R | void`). The union tag is the `instanceof Promise` test, and every operation keeps Node's microtask timing: `await` awaits the promise arm or takes the one non-thenable turn, an async `return` (block bodies, concise arrows, then handlers) adopts the promise arm after the function's own finally blocks, `Promise.resolve` returns the promise arm as is, and `typeof (u as any).then` reads the tag. Data arms that declare `then`, two promise arms, and function arms beside a promise stay refused with diagnostics. Inferred JavaScript literals that mix promises and values keep the checked-dynamic array. Rest parameters typed `unknown[]` or `any[]` in TypeScript use the checked-dynamic variadic ABI that JavaScript rest parameters already use, including spread forwarding (`(...args) => handler(...args)`). Functions are now Map values, Map keys and Set elements, compared by closure identity. Two pre-existing bugs in the same area are fixed: an async lambda in a `Promise | void` slot got a `Promise` ABI and threw when called through the slot, and `return cb()` of such a callback in an async function dropped the promise instead of adopting it. The retired-engine 2251 and 2790 diagnostics fixtures (tagged templates with `any[]` rest tags) now compile and match Node, so they move to the corpus as 6248 and 6249. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-apis/map-and-set.mdx | 2 +- .../null-and-optional-values.mdx | 2 +- .../types-and-class-definitions/unions.mdx | 2 +- .../frontend/lowering/builtins/promises.ts | 29 +- .../src/frontend/lowering/call-arguments.ts | 8 +- .../src/frontend/lowering/call-signatures.ts | 42 ++- .../lowering/coercions/function-adapters.ts | 6 + .../src/frontend/lowering/lower-calls.ts | 31 +- .../src/frontend/lowering/lower-classes.ts | 11 +- .../src/frontend/lowering/lower-exprs.ts | 99 +++++- .../src/frontend/lowering/lower-functions.ts | 18 +- .../compiler/src/frontend/lowering/lowerer.ts | 92 +++++- .../src/frontend/lowering/maybe-promise.ts | 294 ++++++++++++++++++ packages/compiler/src/frontend/type-mapper.ts | 79 ++++- packages/compiler/src/ir/ir.ts | 34 +- packages/compiler/src/ir/validate.test.ts | 30 +- packages/compiler/src/ir/validate.ts | 8 +- .../6240-maybe-promise-await-order.ts.json | 6 + .../6241-maybe-promise-handlers.ts.json | 6 + .../6242-maybe-promise-rejections.ts.json | 6 + .../6243-maybe-promise-narrowing.ts.json | 6 + .../6244-maybe-promise-async-return.ts.json | 6 + .../corpus/6245-rest-unknown-handlers.ts.json | 6 + .../6246-map-set-function-values.ts.json | 6 + .../6247-next-proxy-maybe-promise.ts.json | 6 + .../6248-tagged-template-any-rest.ts.json | 6 + ...ged-template-missing-substitutions.ts.json | 6 + .../diagnostics/maybe-promise-fences.ts.json | 6 + ...d-engine-2251-tagged-templates-dyn.ts.json | 6 - ...array-missing-tagged-substitutions.ts.json | 6 - .../corpus/6240-maybe-promise-await-order.ts | 60 ++++ tests/corpus/6241-maybe-promise-handlers.ts | 77 +++++ tests/corpus/6242-maybe-promise-rejections.ts | 77 +++++ tests/corpus/6243-maybe-promise-narrowing.ts | 61 ++++ .../corpus/6244-maybe-promise-async-return.ts | 61 ++++ tests/corpus/6245-rest-unknown-handlers.ts | 46 +++ tests/corpus/6246-map-set-function-values.ts | 52 ++++ tests/corpus/6247-next-proxy-maybe-promise.ts | 35 +++ .../6248-tagged-template-any-rest.ts} | 0 ...-tagged-template-missing-substitutions.ts} | 0 tests/diagnostics/component-fences.ts | 6 +- tests/diagnostics/maps.ts | 2 +- tests/diagnostics/maybe-promise-fences.ts | 37 +++ .../__snapshots__/component-fences.ts.txt | 30 +- tests/harness/__snapshots__/maps.ts.txt | 16 - .../__snapshots__/maybe-promise-fences.ts.txt | 65 ++++ ...ed-engine-2251-tagged-templates-dyn.ts.txt | 44 --- ...-array-missing-tagged-substitutions.ts.txt | 17 - 48 files changed, 1388 insertions(+), 163 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/maybe-promise.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json create mode 100644 tests/corpus/6240-maybe-promise-await-order.ts create mode 100644 tests/corpus/6241-maybe-promise-handlers.ts create mode 100644 tests/corpus/6242-maybe-promise-rejections.ts create mode 100644 tests/corpus/6243-maybe-promise-narrowing.ts create mode 100644 tests/corpus/6244-maybe-promise-async-return.ts create mode 100644 tests/corpus/6245-rest-unknown-handlers.ts create mode 100644 tests/corpus/6246-map-set-function-values.ts create mode 100644 tests/corpus/6247-next-proxy-maybe-promise.ts rename tests/{diagnostics/retired-engine-2251-tagged-templates-dyn.ts => corpus/6248-tagged-template-any-rest.ts} (100%) rename tests/{diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts => corpus/6249-tagged-template-missing-substitutions.ts} (100%) create mode 100644 tests/diagnostics/maybe-promise-fences.ts create mode 100644 tests/harness/__snapshots__/maybe-promise-fences.ts.txt delete mode 100644 tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt delete mode 100644 tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt diff --git a/docs/content/limitations/builtin-apis/map-and-set.mdx b/docs/content/limitations/builtin-apis/map-and-set.mdx index 04ebcbfca..1c9c28c20 100644 --- a/docs/content/limitations/builtin-apis/map-and-set.mdx +++ b/docs/content/limitations/builtin-apis/map-and-set.mdx @@ -1 +1 @@ -- **Map and Set:** Keys and elements support numbers and strings by value, and records, classes, arrays, symbols, server handles, and unions of these reference types by identity. `unknown` slots use native checked values, comparing supported primitives by value and retaining supported object references. Typed nested collections other than `Map` and `Set`, and promises requiring payload adapters, cannot pass into these slots. `Promise` retains its native representation. Stored `keys()`, `values()`, and `entries()` iterators compile, retain element identity, and observe live collection mutations until exhausted. They support `.next()`, `for...of`, array spreads, and mapper-less `Array.from`. Detached collection methods and iterator helper methods such as `.map()` and `.take()` are unsupported. The Set composition methods (`union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`) accept a Set of the same element type or a Map keyed by it; other set-like arguments are unsupported. +- **Map and Set:** Keys and elements support numbers and strings by value, and records, classes, arrays, functions, symbols, server handles, and unions of these reference types by identity. Map values may also be functions. `unknown` slots use native checked values, comparing supported primitives by value and retaining supported object references. Typed nested collections other than `Map` and `Set`, and promises requiring payload adapters, cannot pass into these slots. `Promise` retains its native representation. Stored `keys()`, `values()`, and `entries()` iterators compile, retain element identity, and observe live collection mutations until exhausted. They support `.next()`, `for...of`, array spreads, and mapper-less `Array.from`. Detached collection methods and iterator helper methods such as `.map()` and `.take()` are unsupported. The Set composition methods (`union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`) accept a Set of the same element type or a Map keyed by it; other set-like arguments are unsupported. diff --git a/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx b/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx index f3d4e6acf..afb8ec9cf 100644 --- a/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx +++ b/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx @@ -1 +1 @@ -- **Null and optional values:** `undefined` and `null` compile as standalone values and union members. Optional record and class fields and optional, default, and rest parameters also compile. +- **Null and optional values:** `undefined` and `null` compile as standalone values and union members. Optional record and class fields and optional, default, and rest parameters also compile, including rest parameters typed `unknown[]` or `any[]`, whose functions take any number of arguments. A function value with fixed parameters cannot be stored where such a variadic function type is expected. diff --git a/docs/content/limitations/types-and-class-definitions/unions.mdx b/docs/content/limitations/types-and-class-definitions/unions.mdx index 5d3bae3c3..403c9be2e 100644 --- a/docs/content/limitations/types-and-class-definitions/unions.mdx +++ b/docs/content/limitations/types-and-class-definitions/unions.mdx @@ -1 +1 @@ -- **Unions:** Narrow a union before an operation whose members require incompatible native representations, such as reading `u.length` on `string | string[]`. Some union-to-union widening and unions combining function and data members are unsupported. Same-representation methods on class unions, `map` and `forEach` on array unions, and shared or joinable field reads compile. `console.log(u)` supports printing a whole union. +- **Unions:** Narrow a union before an operation whose members require incompatible native representations, such as reading `u.length` on `string | string[]`. Some union-to-union widening and unions combining function and data members are unsupported. Same-representation methods on class unions, `map` and `forEach` on array unions, and shared or joinable field reads compile. `console.log(u)` supports printing a whole union. A union may hold one promise beside other members, such as `T | Promise` or `Promise | R | void`: `await`, an async function's `return`, `Promise.resolve`, and `instanceof Promise` act on the member it holds, with Node's microtask timing. A promise member beside a member that declares `then`, two promise members in one union, and `console.log` of a union holding a promise are unsupported. diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index 2dc212f81..da680a86e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -2,6 +2,7 @@ import { checkedPromiseAll } from "../checked-promise-all.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; +import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { DYN, @@ -402,6 +403,22 @@ export function lowerPromiseStaticCall( if (v.type.kind !== "promise") lowerer.badType(argNode, argT); return v; } + const maybePromise = argIr ? maybePromiseUnionOf(lowerer, argIr) : null; + if (maybePromise) { + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const v = lowerer.lowerExpr(argNode); + const mp = maybePromiseUnionOf(lowerer, v.type); + const resolved = + resultT?.kind === "promise" && mp + ? maybePromiseResolve(lowerer, v, mp, resultT, loc) + : null; + if (resolved) return resolved; + lowerer.noLowering( + `Promise.resolve over '${lowerer.fmt(v.type)}' at this result type`, + argNode, + "the promise arm passes through identically, so it must already be the result's promise type", + ); + } if ( argIr?.kind === "union" && (lowerer.unions.get(argIr.unionId)?.arms ?? []).some((a) => a.kind === "promise") @@ -423,7 +440,17 @@ export function lowerPromiseStaticCall( } } } - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + let resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + // A maybe-promise context (`return Promise.resolve(v)` from a function + // returning `Promise | R | void`) names its promise arm: the + // fresh promise takes that payload type directly. + if (argNode !== undefined && resultT?.kind === "promise") { + const ctxT = lowerer.checker.getContextualType(call); + const contextual = ctxT?.isUnionType() ? lowerer.mapTypeOf(ctxT) : null; + const mp = contextual ? maybePromiseUnionOf(lowerer, contextual) : null; + if (mp && !typeEquals(mp.promise, resultT) && mp.promise.inner.kind !== "void") + resultT = mp.promise; + } if (resultT?.kind !== "promise") { lowerer.noLowering( "Promise.resolve at this type", diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index d1d695920..3119fa2ab 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -546,7 +546,13 @@ export function lowerSpreadArgsCall( callee: IrExpr, loc: SrcLoc, ): IrExpr | null { - if (!isJsSourceFile(expr.getSourceFile())) return null; + // TypeScript reaches this lane only for checked-dynamic variadic callees + // (`(...args: unknown[]) => R`), whose ABI is the JS one. + if ( + !isJsSourceFile(expr.getSourceFile()) && + !(callee.type.kind === "func" && callee.type.rest === true && callee.type.restAbi === undefined) + ) + return null; if (!expr.arguments.some((a) => ts.isSpreadElement(a))) return null; if (callee.type.kind !== "dyn" && callee.type.kind !== "func") return null; const calleeName = diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 8e30e8b82..ae59be3b2 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -13,7 +13,7 @@ import { typeEquals, } from "../../ir/ir.js"; import { isJsSourceFile, isNodeEsmFile } from "../program.js"; -import { genResultRecord, jsOpenObjectType } from "../type-mapper.js"; +import { genResultRecord, isAnyArrayType, jsOpenObjectType } from "../type-mapper.js"; import { dynFallbackType } from "./lowerer.js"; import { builtinModuleFnOf } from "./surfaces.js"; import { mixinFnShapeOf } from "./lower-mixins.js"; @@ -286,10 +286,17 @@ export function paramShape(lowerer: Lowerer, param: ts.ParameterDeclaration): Pa return { type: DYN, mode: "dynRest" }; } } + // `...args: unknown[]` / `...args: any[]` in TypeScript: the binding is + // the checked-dynamic array (how `unknown[]` maps everywhere), so the + // ABI is the JS variadic dyn form above — the function type marks it + // with `rest` and no typed slot (the type mapper agrees). + const restTs = lowerer.typeOf(param.name); + if (isAnyArrayType(restTs, lowerer.checker)) return { type: DYN, mode: "dynRest" }; const type = lowerer.runtimeOptionalBindingType(param.name, lowerer.irTypeOf(param.name)); + if (type.kind === "dyn") return { type: DYN, mode: "dynRest" }; // Tuple-typed rest params don't map to an array; generic rest is the // generic path's business. Anything non-array here is unmappable. - if (type.kind !== "array") lowerer.badType(param.name, lowerer.typeOf(param.name)); + if (type.kind !== "array") lowerer.badType(param.name, restTs); return { type, mode: "rest" }; } if (param.initializer) { @@ -843,13 +850,34 @@ export function lambdaSignature( ctxDecl && !ctxDecl.getSourceFile().isDeclarationFile ? lowerer.mapTypeOf(lowerer.checker.getReturnTypeOfSignature(ctxSigs[0]!)) : null; - const ctxRet = isAsyncLike && ctxRetRaw?.kind === "promise" ? ctxRetRaw.inner : ctxRetRaw; + // An async lambda always produces a promise, so a slot returning a + // promise-armed union (`Promise | R | void`, `Promise + // | void`) is met through that promise arm: its payload is the ABI. + const ctxPromiseArm = + isAsyncLike && ctxRetRaw?.kind === "union" + ? lowerer.unions.get(ctxRetRaw.unionId)?.arms.filter((arm) => arm.kind === "promise") + : undefined; + const ctxRet = + isAsyncLike && ctxRetRaw?.kind === "promise" + ? ctxRetRaw.inner + : ctxPromiseArm?.length === 1 && ctxPromiseArm[0]!.kind === "promise" + ? ctxPromiseArm[0]!.inner + : ctxRetRaw; + // A TypeScript variadic lambda (`(...args) => handler(...args)`) in a + // checked-dynamic variadic slot returning `unknown` adopts that result + // too: rest closures have no function-value adapter, so the closure + // must match the slot's ABI exactly. + const dynVariadicSlot = + ctxRet?.kind === "dyn" && + !isJsSourceFile(node.getSourceFile()) && + shapes.some((shape) => shape.mode === "dynRest"); if ( - (ctxRet?.kind === "union" || ctxRet?.kind === "array" || ctxRet?.kind === "record") && - (innerRet.kind !== "void" || - (ctxRet.kind === "union" && lowerer.armTag(ctxRet.unionId, UNDEFINED_T) >= 0)) + dynVariadicSlot || + ((ctxRet?.kind === "union" || ctxRet?.kind === "array" || ctxRet?.kind === "record") && + (innerRet.kind !== "void" || + (ctxRet.kind === "union" && lowerer.armTag(ctxRet.unionId, UNDEFINED_T) >= 0))) ) { - ret = isAsyncLike ? { kind: "promise", inner: ctxRet } : ctxRet; + ret = isAsyncLike ? { kind: "promise", inner: ctxRet! } : ctxRet!; } // A VOID slot discards the callback's result (TS's void-returning // assignability rule; JS ignores the value), so an UNANNOTATED diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index f60fc32b8..b99c24a15 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -13,6 +13,7 @@ import { } from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import type { Lowerer } from "../lowerer.js"; +import { maybePromiseUnionOf } from "../maybe-promise.js"; /** Wrap a zero-argument function whose array result needs an element * conversion. Each invocation creates a fresh converted array; the factory @@ -96,6 +97,11 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool if (dst.kind === "union") { if (src.kind === "union") return lowerer.unionRetagMappable(src.unionId, dst.unionId); if (src.kind === "void") return lowerer.armTag(dst.unionId, UNDEFINED_T) >= 0; + if (src.kind === "promise") { + // A maybe-promise union's promise arm accepts payload widening. + const mp = maybePromiseUnionOf(lowerer, dst); + if (mp) return lowerer.promisePayloadWidens(src, mp.promise); + } return !isUnitType(src) && lowerer.armTag(dst.unionId, src) >= 0; } if (src.kind === "union") { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..fc7069b41 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -207,6 +207,7 @@ import { } from "./call-arguments.js"; import { isThisParameter, type ParamShape } from "./call-signatures.js"; import { checkedArmExtraction } from "./coercions/unions.js"; +import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; import { requireObjLitGenericReceiver, objLitGenericFnNodeOf, @@ -5217,6 +5218,8 @@ export function lowerPromiseMethodCall( const resultOf = (value: IrExpr, returnsPromise = false): IrStmt[] => { if (value.type.kind === "promise") return [lowerer.adoptingReturn(value, R, (e) => lowerer.coerceInto(call, e, R))]; + const flattened = maybePromiseHandlerResult(lowerer, call, value, R); + if (flattened) return [flattened]; if (returnsPromise) { // A boxed handler's promise result adopts too: the then job's // turn after the call, then the await of the returned value. @@ -5567,7 +5570,10 @@ export function lowerPromiseMethodCall( // The handler's result: promise returns flatten exactly like // `return p` in any async body (the spec's thenable adoption); // everything else coerces into R. - if (handlerCall.type.kind === "promise" && R.kind !== "promise") { + const flattened = maybePromiseHandlerResult(lowerer, call, handlerCall, R); + if (flattened) { + body.push(flattened); + } else if (handlerCall.type.kind === "promise" && R.kind !== "promise") { body.push(lowerer.adoptingReturn(handlerCall, R, (e) => lowerer.coerceInto(call, e, R))); } else if (R.kind === "void") { body.push({ kind: "exprStmt", expr: handlerCall, loc }); @@ -9634,3 +9640,26 @@ export function lowerStaticNumberGlobal( } return null; } + +/** A then handler's promise-armed result (`.then(() => cached ?? load())` + * typed `T | Promise`, or a `Promise | void` callback's result): + * the lifted async body adopts the promise arm and fulfills with the + * others, like `return u` in an async function. Null for other results. */ +function maybePromiseHandlerResult( + lowerer: Lowerer, + call: ts.CallExpression, + value: IrExpr, + R: IrType, +): IrStmt | null { + const mp = promiseArmedUnionOf(lowerer, value.type); + if (!mp || promiseArmedUnionOf(lowerer, R)) return null; + const stmt = maybePromiseReturnStmt(lowerer, value, mp, R, (e) => lowerer.coerceInto(call, e, R)); + if (stmt === null) { + lowerer.unsupported( + "SC1090", + call, + `then handlers returning '${lowerer.fmt(value.type)}' into '${lowerer.fmt(R)}' (an arm does not convert to the result)`, + ); + } + return stmt; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c3..d09f40ed8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1566,7 +1566,14 @@ function inferredEmptyCollectionFieldType( const mappedArgument = (argument: ts.Expression): IrType | null => { const type = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(argument)); const mapped = lowerer.mapTypeOf(type); - if (!mapped || mapped.kind === "dyn" || mapped.kind === "void" || isUnitType(mapped)) + // Inferred JS collection fields keep functions in checked slots. + if ( + !mapped || + mapped.kind === "dyn" || + mapped.kind === "void" || + mapped.kind === "func" || + isUnitType(mapped) + ) return null; return mapped; }; @@ -9231,7 +9238,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { "SC1090", expr, `Map values of type '${lowerer.checker.typeToString(targs[1])}' ` + - `(Map values must be number, string, boolean, records, class instances, ` + + `(Map values must be number, string, boolean, records, class instances, functions, ` + `typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values)`, ); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 149c25248..53cb26093 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -45,6 +45,11 @@ import { constituentTypes } from "../ts7/checker.js"; import { dirname } from "node:path"; import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; +import { + lowerMaybePromiseAwait, + maybePromiseInstanceOf, + maybePromiseUnionOf, +} from "./maybe-promise.js"; import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js"; @@ -1766,6 +1771,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { } return { kind: "awaitUnionExpr", value, promiseTag, type, loc }; } + const maybePromise = maybePromiseUnionOf(lowerer, value.type); + if (maybePromise) return lowerMaybePromiseAwait(lowerer, expr, value, maybePromise, loc); } if (value.type.kind !== "promise") { // A CHECKED-DYNAMIC operand (`await v` where v rode an untyped @@ -4699,12 +4706,61 @@ export function lowerCondition(lowerer: Lowerer, expr: ts.Expression): IrExpr { ); } +/** `typeof (u as any).then` / `if ((u as Promise).then)` over a + * maybe-promise union `u` (the cast is erasure in JS): a promise arm has + * the prototype's `then` function, and no data arm is thenable (the type + * mapper's rule), so the union tag answers. Null for every other shape. */ +function maybePromiseThenPresence( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, + result: IrExpr, +): IrExpr | null { + let receiver: ts.Expression = access.expression; + let cast = false; + for (;;) { + if (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression; + else if (ts.isAsExpression(receiver) || ts.isTypeAssertion(receiver)) { + receiver = receiver.expression; + cast = true; + } else break; + } + if (!cast) return null; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(receiver)); + if (!mapped || !maybePromiseUnionOf(lowerer, mapped)) return null; + const value = lowerer.lowerExpr(receiver); + const mp = maybePromiseUnionOf(lowerer, value.type); + if (!mp) return null; + const loc = locOf(access); + const absent: IrExpr = + result.type.kind === "bool" + ? { kind: "boolLit", value: false, type: BOOL, loc } + : { kind: "strLit", value: "undefined", type: STRING, loc }; + return { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: mp.def.id, + tag: mp.promiseTag, + negated: false, + value, + type: BOOL, + loc, + }, + then: result, + else_: absent, + type: result.type, + loc, + }; +} + function lowerPromiseThenPresence( lowerer: Lowerer, access: ts.PropertyAccessExpression, result: IrExpr, ): IrExpr | null { if (access.name.text !== "then") return null; + const maybePromise = maybePromiseThenPresence(lowerer, access, result); + if (maybePromise !== null) return maybePromise; const mapped = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); const promiseType = mapped?.kind === "promise" @@ -5888,6 +5944,11 @@ export function lowerArrayLiteral( const elem = mapped.elem; const arms = elem.kind === "union" ? lowerer.unions.get(elem.unionId)?.arms : [elem]; if (arms?.length && arms.every((arm) => arm.kind === "classval")) mapped = DYN; + // Inferred JavaScript literals mixing promises and plain values + // (`[Promise.resolve(1), 2]`) keep the checked-dynamic array: their + // consumers (Readable.from, Promise.all over iterators) inspect each + // element at run time. + if (maybePromiseUnionOf(lowerer, elem)) mapped = DYN; } if ( !expected && @@ -12607,6 +12668,38 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc }; } } + // `x instanceof Promise` over a promise-armed union (the maybe-promise + // `T | Promise` dispatch): a union tag test — the data arms are + // never promises (scriptc promises are all native, and no data arm + // can hold one). A promise-typed operand answers true after its + // evaluation; a checked-dynamic one asks the runtime. + if ( + ts.isIdentifier(expr.right) && + lowerer.isStdlibGlobal(expr.right, "Promise") && + !lowerer.caughtLocalOf(expr.left) + ) { + const left = lowerer.lowerExpr(expr.left); + const tagTest = maybePromiseInstanceOf(lowerer, left, loc); + if (tagTest) return tagTest; + if (left.type.kind === "dyn") + return { kind: "dynTest", test: "promise", value: left, type: BOOL, loc }; + if (left.type.kind === "promise") { + const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + if (isSafeToDiscard(left)) return yes; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: yes, + type: BOOL, + loc, + }; + } + lowerer.unsupported( + "SC1090", + expr, + `'instanceof Promise' on '${lowerer.fmt(left.type)}' values (only promises, promise-armed unions, and checked-dynamic values have a lowering)`, + ); + } // `x instanceof RegExp` over a union with a regex arm (the // skip-utility `string | RegExp` dispatch): a union tag test — the // socket-narrowing shape; the checker types the branches and @@ -15778,7 +15871,11 @@ export function lowerPromiseRejectCall( ); } const ctxT = lowerer.checker.getContextualType(call); - const type = ctxT ? lowerer.mapTypeOf(ctxT) : null; + const contextual = ctxT ? lowerer.mapTypeOf(ctxT) : null; + // A maybe-promise context (`return Promise.reject(e)` from a function + // returning `T | Promise`) names its one promise arm. + const maybePromise = contextual ? maybePromiseUnionOf(lowerer, contextual) : null; + const type = maybePromise ? maybePromise.promise : contextual; const reasonNode = call.arguments[0]!; // A CHECKED-DYNAMIC reason (`Promise.reject(value)` where value rode // an untyped binding — the tracingChannel suite's shape): the checked-dynamic tree diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 2991c0d39..c7e293707 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -345,9 +345,12 @@ export function lowerLambda( } else { const value = lowerer.lowerExpr(bodyExpr); // `async () => p` with a void result still resolves the - // function's promise with p (its settlement, rejections included). - body = - value.kind === "unitLit" + // function's promise with p (its settlement, rejections included); + // a maybe-promise value adopts only its promise arm. + const flattened = isAsync ? lowerer.maybePromiseReturn(bodyExpr, value, VOID) : null; + body = flattened + ? [flattened] + : value.kind === "unitLit" ? [] : isAsync && value.type.kind === "promise" ? [lowerer.adoptingReturn(value, VOID, (e) => e)] @@ -367,8 +370,13 @@ export function lowerLambda( if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") { value = lowerer.returnedPromiseAwait(value); } - body = - value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!)) + const flattened = + isAsync && !fnCtx.inferReturn + ? lowerer.maybePromiseReturn(bodyExpr, value, bodyReturn) + : null; + body = flattened + ? [flattened] + : value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!)) ? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }] : [ { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a1ded6aee..f7b5febcc 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -596,6 +596,11 @@ import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { lowerTernary } from "./lower-exprs.js"; +import { + maybePromiseReturnStmt, + maybePromiseUnionOf, + promiseArmedUnionOf, +} from "./maybe-promise.js"; /** Entry function name. '%' cannot appear in a TS identifier, so a user * function can never collide with it (mangling is injective per prefix). */ @@ -1275,17 +1280,20 @@ export function dynFallbackType(lowerer: Lowerer, node: ts.Node, t: ts.Type): Ir if (args.length === 2) { const key = lowerer.mapTypeOf(args[0]!); const value = lowerer.mapTypeOf(args[1]!); + // Inferred JS function types keep the checked-dynamic slot (their + // closures carry JS arity, not a typed closure ABI). return { kind: "map", key: key && + key.kind !== "func" && isSupportedMapKey( key, key.kind === "union" ? lowerer.unions.get(key.unionId)?.arms : undefined, ) ? key : DYN, - value: value && isSupportedMapValue(value) ? value : DYN, + value: value && value.kind !== "func" && isSupportedMapValue(value) ? value : DYN, }; } } @@ -8204,6 +8212,18 @@ export class Lowerer { // undefined/null literals store the dyn unit values. Types outside the // dyn's domain (bytes, classes, Maps, ...) fall through to // requireExactShape's SC1101 fence. + // A promise entering a maybe-promise union whose promise arm has a + // wider payload (`Promise` into `Promise | R | void`) + // takes the payload widening below first, then wraps into that arm. + if (expected.kind === "union" && expr.type.kind === "promise") { + const mp = maybePromiseUnionOf(this, expected); + if ( + mp && + !typeEquals(mp.promise, expr.type) && + this.promisePayloadWidens(expr.type, mp.promise) + ) + expr = this.coerceToExpected(expr, mp.promise); + } // Promise payloads may widen at a checked return boundary, including // arrays inferred as tuple results by Promise.all. Keep both payload // ABIs explicit: box settlement, then validate the destination payload. @@ -9402,6 +9422,25 @@ export class Lowerer { return type; } + /** Whether a promise of `from` converts to a promise of `to` through the + * checked payload widening (coerceToExpected's boxed-settlement rule, + * which keeps the promise's identity and settlement turn). */ + promisePayloadWidens( + from: IrType & { kind: "promise" }, + to: IrType & { kind: "promise" }, + ): boolean { + if (typeEquals(from, to)) return true; + if (to.inner.kind === "void") return from.inner.kind !== "void"; + return ( + this.dynConvertible(from) && + canDynCheckTo( + to, + (id) => this.shapes.get(id), + (id) => this.unions.get(id), + ) + ); + } + /** True when a static type converts to a dyn value (the dynFrom * walker's domain): JSON-safe, bytes (Uint8Array/Buffer — the checked-dynamic tree's * bytes kind, payload copied; stdin chunks into unknown-typed helpers), @@ -9441,16 +9480,26 @@ export class Lowerer { // storage directly, just like arguments and variable initializers. // Contextual types (such as Proxy descriptors' symbol-valued fields) // must not force an intermediate record with incompatible field slots. + if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected); + return this.asyncReturnValueOf(node, this.lowerExpr(node), expected); + } + + /** A fresh JS object literal returned into a checked-native (dyn) result, + * which lowers straight into that storage. */ + private returnsLiteralIntoDyn(node: ts.Expression, expected: IrType): boolean { let literal = node; while (ts.isParenthesizedExpression(literal)) literal = literal.expression; - if ( + return ( expected.kind === "dyn" && isJsSourceFile(node.getSourceFile()) && ts.isObjectLiteralExpression(literal) - ) { - return this.lowerExprExpecting(node, expected); - } - const e = this.lowerExpr(node); + ); + } + + /** The value of `return ` (already lowered untyped) in an async or + * void context: a promise flattens through the returned-promise await, + * everything else converts into the declared result. */ + private asyncReturnValueOf(node: ts.Expression, e: IrExpr, expected: IrType): IrExpr | null { if (expected.kind === "void" && e.kind === "unitLit") return null; if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") { const awaited = this.returnedPromiseAwait(e); @@ -9516,10 +9565,32 @@ export class Lowerer { return s; } + /** `return u` of a promise-armed union (maybe-promise, or promise-or- + * unit) in an async function: adopt the promise arm, fulfill with the + * others (maybe-promise.ts). Null when the return is not that shape. */ + maybePromiseReturn(node: ts.Expression, e: IrExpr, expected: IrType): IrStmt | null { + if (!this.adoptsReturnedPromise()) return null; + const mp = promiseArmedUnionOf(this, e.type); + if (!mp || promiseArmedUnionOf(this, expected)) return null; + const stmt = maybePromiseReturnStmt(this, e, mp, expected, (x) => + this.coerceInto(node, x, expected), + ); + if (stmt === null) { + this.unsupported( + "SC1090", + node, + `returning '${this.fmt(e.type)}' from an async function whose result is '${this.fmt(expected)}' (an arm does not convert to the result)`, + ); + } + return stmt; + } + private lowerReturnOf(node: ts.Expression, loc: SrcLoc): IrStmt { const expected = this.ctx.returnType; if (expected.kind === "void") { let e = this.lowerExpr(node); + const flattened = this.maybePromiseReturn(node, e, expected); + if (flattened) return flattened; if (this.ctx.isAsync && e.type.kind === "promise") { if (this.adoptsReturnedPromise()) return this.adoptingReturn(e, VOID, (x) => x); e = this.returnedPromiseAwait(e); @@ -9535,6 +9606,15 @@ export class Lowerer { loc, }; } + if (this.adoptsReturnedPromise() && !this.returnsLiteralIntoDyn(node, expected)) { + // The async return lowers its operand untyped first (a promise + // adopts before its payload converts); a maybe-promise union needs + // the statement form, which branches between adoption and fulfillment. + const e = this.lowerExpr(node); + const flattened = this.maybePromiseReturn(node, e, expected); + if (flattened) return flattened; + return { kind: "return", value: this.asyncReturnValueOf(node, e, expected), loc }; + } return { kind: "return", value: this.lowerReturnValue(node), loc }; } diff --git a/packages/compiler/src/frontend/lowering/maybe-promise.ts b/packages/compiler/src/frontend/lowering/maybe-promise.ts new file mode 100644 index 000000000..8323c2e91 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/maybe-promise.ts @@ -0,0 +1,294 @@ +import { varRef } from "../../ir/build.js"; +import { BOOL, isUnitType, typeEquals, VOID } from "../../ir/ir.js"; +import type { IrExpr, IrStmt, IrType, IrUnionDef, SrcLoc } from "../../ir/ir.js"; +import type * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** A MAYBE-PROMISE union (`T | Promise`, `Promise | R | + * void`): one promise arm beside at least one data arm. The type mapper + * mints these only when no data arm is thenable, so the union tag decides + * exactly what JS's await decides: the promise arm settles, every other + * arm is a non-thenable that answers itself. Promise-or-unit unions keep + * their own lowerings (awaitUnionExpr). */ +export interface MaybePromiseUnion { + def: IrUnionDef; + promiseTag: number; + promise: IrType & { kind: "promise" }; +} + +export function maybePromiseUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null { + const armed = promiseArmedUnionOf(lowerer, t); + if (!armed || armed.def.arms.every((a, i) => i === armed.promiseTag || isUnitType(a))) + return null; + return armed; +} + +/** Any union with exactly one promise arm: a maybe-promise union or a + * promise-or-unit union (`Promise | void`). An async `return` + * resolves both the same way: adopt the promise arm, fulfill otherwise. */ +export function promiseArmedUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null { + if (t.kind !== "union") return null; + const def = lowerer.unions.get(t.unionId); + if (!def) return null; + const tags = def.arms.flatMap((a, i) => (a.kind === "promise" ? [i] : [])); + if (tags.length !== 1) return null; + const promiseTag = tags[0]!; + const promise = def.arms[promiseTag]!; + if (promise.kind !== "promise") return null; + return { def, promiseTag, promise }; +} + +function unionTagTest(mp: MaybePromiseUnion, value: IrExpr, tag: number, loc: SrcLoc): IrExpr { + return { + kind: "unionIsTag", + unionId: mp.def.id, + tag, + negated: false, + value, + type: BOOL, + loc, + }; +} + +/** The value of `ref` (a repeatable read of the maybe-promise union) as + * `target`, given that it does not hold the promise arm: each remaining + * arm extracts and converts. Null when some arm cannot convert. */ +function nonPromiseArmsAs( + lowerer: Lowerer, + mp: MaybePromiseUnion, + ref: IrExpr, + target: IrType, + loc: SrcLoc, +): IrExpr | null { + const pieces: { tag: number; value: IrExpr }[] = []; + for (let tag = 0; tag < mp.def.arms.length; tag++) { + if (tag === mp.promiseTag) continue; + const arm = mp.def.arms[tag]!; + const source: IrExpr = + arm.kind === "undefinedT" || arm.kind === "nullT" + ? { + kind: "unitLit", + unit: arm.kind === "undefinedT" ? "undefined" : "null", + type: arm, + loc, + } + : { kind: "unionNarrow", unionId: mp.def.id, tag, value: ref, type: arm, loc }; + const value = lowerer.coerceToExpected(source, target); + if (!typeEquals(value.type, target)) return null; + pieces.push({ tag, value }); + } + let result: IrExpr | null = null; + for (let i = pieces.length - 1; i >= 0; i--) { + const piece = pieces[i]!; + result = + result === null + ? piece.value + : { + kind: "ternary", + cond: unionTagTest(mp, ref, piece.tag, loc), + then: piece.value, + else_: result, + type: target, + loc, + }; + } + return result; +} + +function hopStmt(loc: SrcLoc): IrStmt { + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "async.hop", args: [], type: VOID, loc }, + loc, + }; +} + +/** `await u` over a maybe-promise union. JS's await resolves the operand: + * a native promise is awaited directly (resumption one turn after it + * settles; a rejection throws here), and any other non-thenable value + * resumes after exactly one microtask turn with the value itself. The + * result type is the checker's type of the await expression (`Awaited` + * of the union), which holds the promise's settled type and every data + * arm. */ +export function lowerMaybePromiseAwait( + lowerer: Lowerer, + expr: ts.AwaitExpression, + value: IrExpr, + mp: MaybePromiseUnion, + loc: SrcLoc, +): IrExpr { + const resultT = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (!resultT || resultT.kind === "void" || resultT.kind === "dyn" || isUnitType(resultT)) { + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (the awaited result type has no compiled representation)`, + ); + } + const local = lowerer.declareHiddenLocal("%maybePromise", value.type); + const ref = varRef(local.id, value.type, loc); + const inner = mp.promise.inner; + const awaited: IrExpr = { + kind: "awaitExpr", + value: { + kind: "unionNarrow", + unionId: mp.def.id, + tag: mp.promiseTag, + value: ref, + type: mp.promise, + loc, + }, + type: inner, + loc, + }; + let settled: IrExpr | null; + if (inner.kind === "void") { + const undef = lowerer.wrappedUndefined(resultT, loc); + settled = + undef === null + ? null + : { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: awaited, loc }], + result: undef, + type: resultT, + loc, + }; + } else { + settled = lowerer.coerceToExpected(awaited, resultT); + if (!typeEquals(settled.type, resultT)) settled = null; + } + const rest = nonPromiseArmsAs(lowerer, mp, ref, resultT, loc); + if (settled === null || rest === null) { + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (its arms do not all convert to the awaited result '${lowerer.fmt(resultT)}')`, + ); + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result: { + kind: "ternary", + cond: unionTagTest(mp, ref, mp.promiseTag, loc), + then: settled, + else_: { kind: "seqExpr", stmts: [hopStmt(loc)], result: rest, type: resultT, loc }, + type: resultT, + loc, + }, + type: resultT, + loc, + }; +} + +/** `return u` of a promise-armed union from an async function (or a then + * handler's lifted body) whose settled type is `R`: the promise arm is + * adopted exactly like `return p` (the spec's thenable resolution, with + * its then-job turn), and the other arms fulfill directly. Null when an + * arm cannot convert to R. */ +export function maybePromiseReturnStmt( + lowerer: Lowerer, + value: IrExpr, + mp: MaybePromiseUnion, + R: IrType, + coerce: (awaited: IrExpr) => IrExpr, +): IrStmt | null { + const loc = value.loc; + const local = lowerer.declareHiddenLocal("%maybePromise", value.type); + const ref = varRef(local.id, value.type, loc); + const promise: IrExpr = { + kind: "unionNarrow", + unionId: mp.def.id, + tag: mp.promiseTag, + value: ref, + type: mp.promise, + loc, + }; + let otherwise: IrStmt[]; + if (R.kind === "void") { + otherwise = [{ kind: "return", value: null, loc }]; + } else { + const rest = nonPromiseArmsAs(lowerer, mp, ref, R, loc); + if (rest === null) return null; + otherwise = [{ kind: "return", value: rest, loc }]; + } + return { + kind: "block", + body: [ + { kind: "varDecl", localId: local.id, init: value, loc }, + { + kind: "if", + cond: unionTagTest(mp, ref, mp.promiseTag, loc), + then: [lowerer.adoptingReturn(promise, R, coerce)], + else_: otherwise, + loc, + }, + ], + loc, + }; +} + +/** `Promise.resolve(u)` over a maybe-promise union: a promise argument is + * returned as is (every scriptc promise is native, so the spec's + * constructor check answers identity), any other value fulfills a fresh + * promise. Null when the promise arm is not already the result type (no + * identity-preserving payload adapter exists) or a data arm cannot convert. */ +export function maybePromiseResolve( + lowerer: Lowerer, + value: IrExpr, + mp: MaybePromiseUnion, + resultT: IrType & { kind: "promise" }, + loc: SrcLoc, +): IrExpr | null { + const local = lowerer.declareHiddenLocal("%maybePromise", value.type); + const ref = varRef(local.id, value.type, loc); + let promise: IrExpr = { + kind: "unionNarrow", + unionId: mp.def.id, + tag: mp.promiseTag, + value: ref, + type: mp.promise, + loc, + }; + if (!typeEquals(mp.promise, resultT)) { + if (resultT.inner.kind !== "void") return null; + promise = { kind: "promiseVoidWiden", value: promise, type: resultT, loc }; + } + let fresh: IrExpr; + if (resultT.inner.kind === "void") { + fresh = { kind: "intrinsic", name: "promise.resolve", args: [], type: resultT, loc }; + } else { + const rest = nonPromiseArmsAs(lowerer, mp, ref, resultT.inner, loc); + if (rest === null) return null; + fresh = { kind: "intrinsic", name: "promise.resolve", args: [rest], type: resultT, loc }; + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result: { + kind: "ternary", + cond: unionTagTest(mp, ref, mp.promiseTag, loc), + then: promise, + else_: fresh, + type: resultT, + loc, + }, + type: resultT, + loc, + }; +} + +/** `u instanceof Promise` over a maybe-promise (or promise-or-unit) union: + * the union tag test. */ +export function maybePromiseInstanceOf( + lowerer: Lowerer, + value: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const def = lowerer.unions.get(value.type.unionId); + const tag = def ? def.arms.findIndex((a) => a.kind === "promise") : -1; + if (!def || tag < 0) return null; + return { kind: "unionIsTag", unionId: def.id, tag, negated: false, value, type: BOOL, loc }; +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 7ecf44659..dc7aca503 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -34,6 +34,7 @@ import { SYMBOL_T, typeEquals, typeKey, + maybePromiseUnionArmsOk, unionContainerArmsOk, UNDEFINED_T, VOID, @@ -3942,6 +3943,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } const params: IrType[] = []; let typedRest = false; + let dynRest = false; for (const p of sig.getParameters()) { const decl = checker.valueDeclarationOf(p); const rest = decl !== undefined && ts.isParameter(decl) && decl.dotDotDotToken !== undefined; @@ -4004,6 +4006,16 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!armed) return null; pt = armed; } + // `...args: unknown[]` / `...args: any[]`: the checked-dynamic + // variadic ABI (no typed slot; the callee's hidden trailing dyn + // array receives every surplus argument, as for JS rest params). + if ( + rest && + (pt?.kind === "dyn" || (!pt && isAnyArrayType(checker.getTypeOfSymbol(p), checker))) + ) { + dynRest = true; + continue; + } if (!pt) return null; if (rest) { if (pt.kind !== "array") return null; @@ -4064,7 +4076,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!ret) return null; return typedRest ? { kind: "func", params, ret, rest: true, restAbi: "typed" } - : funcOf(params, ret); + : dynRest + ? { kind: "func", params, ret, rest: true } + : funcOf(params, ret); } // Records: object types whose members are all data properties (shorthand // methods in type position count — they're func-typed fields) with @@ -4312,7 +4326,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { a.kind === "dyn" || a.kind === "generator", ) || - !unionContainerArmsOk(arms) + !maybePromiseUnionOk(parts, arms, ctx) ) { return null; } @@ -6077,9 +6091,49 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { } } +/** `any[]` (or `readonly any[]`): an array type whose element is `any`. */ +export function isAnyArrayType(t: ts.Type, checker: ts.TypeChecker): boolean { + if (!checker.isArrayType(t)) return false; + const elem = checker.getTypeArguments(t as ts.TypeReference)[0]; + return elem !== undefined && (elem.flags & ts.TypeFlags.Any) !== 0; +} + +/** The maybe-promise union rule (mapTypeInner's union rule; IR + * maybePromiseUnionArmsOk): at most one promise arm, and when it has data + * siblings none of them may be thenable — `await` and async `return` + * dispatch on the union tag, so a data arm whose type declares `then` + * would skip the thenable adoption JS performs. */ +function maybePromiseUnionOk( + parts: readonly ts.Type[], + arms: IrType[], + ctx: TypeMapperCtx, +): boolean { + if (unionContainerArmsOk(arms)) return true; + if (!maybePromiseUnionArmsOk(arms)) return false; + // A function beside a promise (`Promise | (() => Promise)`, the + // assert.rejects argument) keeps its existing representation: callers + // dispatch on callability, not on a then-able promise. + if (arms.some((arm) => arm.kind === "func")) return false; + return maybePromiseThenableArm(parts, ctx) === null; +} + +/** The first non-promise constituent of a promise-armed union whose type + * has a `then` member (a thenable data arm), or null. */ +function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null { + const { checker } = ctx; + for (const part of parts) { + if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null)) continue; + if (mapType(part, ctx)?.kind === "promise") continue; + // Primitive arms answer through their wrapper prototypes, none of + // which has `then`; object arms answer through their own members. + if (checker.getPropertyOfType(part, "then")) return part; + } + return null; +} + /** Arm kinds with no home in a compiled union (mapTypeInner's union rule): * no runtime narrowing test exists against sibling data arms. */ -function armHasUnionHome(arm: IrType, siblingCount: number): boolean { +function armHasUnionHome(arm: IrType): boolean { switch (arm.kind) { case "void": case "union": @@ -6088,9 +6142,6 @@ function armHasUnionHome(arm: IrType, siblingCount: number): boolean { case "generator": case "dyn": return false; - // Promise data siblings still lack a supported narrowing contract. - case "promise": - return siblingCount === 0; default: return true; } @@ -6215,10 +6266,24 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx): if (!mapped) { return `the union shape is supported, but its arm '${text(part)}' does not compile`; } - if (!armHasUnionHome(mapped, dataArms.length - 1)) { + if (!armHasUnionHome(mapped)) { return `the union shape is supported, but '${text(part)}' arms have no home in a compiled union yet (no runtime narrowing test exists against sibling arms)`; } } + const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise"); + if (promiseArms.length > 1) { + return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`; + } + if (promiseArms.length === 1 && dataArms.length > 1) { + const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func"); + if (callable) { + return `the union shape is supported, but its function arm '${text(callable.p)}' sits beside a promise arm, and a maybe-promise union holds only data arms beside its promise`; + } + const thenable = maybePromiseThenableArm(parts, ctx); + if (thenable) { + return `the union shape is supported, but its arm '${text(thenable)}' declares 'then' beside a promise arm, so 'await' would have to adopt it as a thenable (a maybe-promise union's data arms must not be thenable)`; + } + } return null; } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 68a4776f5..be6519a9d 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -667,7 +667,12 @@ export function isIdentityCollectionKey(t: IrType): boolean { t.kind === "object" || t.kind === "array" || t.kind === "netServer" || - t.kind === "symbol" + t.kind === "symbol" || + // A function value is one closure for its whole life (top-level + // functions intern one immortal closure, inner closures allocate once + // per evaluation), so pointer identity is JS function identity. + // Closures are cycle-headered and traced like records. + t.kind === "func" ); } @@ -713,8 +718,8 @@ export function isSupportedSetElem(t: IrType, unionArms?: IrType[]): boolean { return isSupportedMapKey(t, unionArms); } -/** The Map VALUE fence: scalars, supported native references and checked - * values. Functions require checked-value boxing. +/** The Map VALUE fence: scalars, supported native references, closures + * and checked values. * Record/object/union values can point back at the map holding them, which * is exactly why ref-valued maps are cycle-capable (see the backend's * cycle analysis and docs/memory.md). Shared frontend/validator. */ @@ -768,6 +773,12 @@ export function isSupportedMapValue(t: IrType): boolean { // a regex holds no references), map form. case "regex": return true; + // A closure (Map U> — the factory/handler registry): + // the index-signature overflow story (scr_closure adapters; closures + // are cycle-headered and traced, so a handler capturing its own + // registry collects). + case "func": + return true; default: return false; } @@ -783,7 +794,7 @@ export function isSupportedMapValue(t: IrType): boolean { * Nested index-signature RECORDS ride the record kind like any other. * Shared frontend (mapType) / validator. */ export function isSupportedIndexValue(t: IrType): boolean { - return t.kind === "dyn" || t.kind === "func" || isSupportedMapValue(t); + return t.kind === "dyn" || isSupportedMapValue(t); } export function funcOf(params: IrType[], ret: IrType): IrType { @@ -791,14 +802,25 @@ export function funcOf(params: IrType[], ret: IrType): IrType { } /** Maps and sets use tagged union storage and retain their native identity. - * Promises still require nullable-only unions until their data-arm narrowing - * and await boundary have a matching contract. */ + * Joined unions (inferred joins of member reads and array elements) keep + * promises nullable-only; declared maybe-promise unions are admitted by + * maybePromiseUnionArmsOk. */ export function unionContainerArmsOk(arms: IrType[]): boolean { return arms.every( (a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b)), ); } +/** The MAYBE-PROMISE union (`T | Promise`, `Promise | R | + * void`): at most one promise arm, beside unit or data arms. The union tag + * is the `instanceof Promise` test, and `await` / async `return` branch on + * it (the promise arm awaits or adopts, the other arms take the + * non-thenable path). Data arms must not be thenable; the type mapper + * checks that against the checker types before minting such a union. */ +export function maybePromiseUnionArmsOk(arms: IrType[]): boolean { + return arms.filter((a) => a.kind === "promise").length <= 1; +} + /** Canonical, injective text form of an IrType — the building block of * shape/union identity keys, generic-function instantiation keys, and the * backend's per-type helper interning (jsonStringify/dynCheck walkers). diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 8086cc176..e4f423411 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -286,17 +286,25 @@ test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as }, ); -test.each([{ kind: "promise", inner: F64 } as IrType])( - "%j payloads still refuse unrelated data siblings", - (type) => { - const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ - { id: "mixed", arms: [type, STRING, UNDEFINED_T] }, - ]); - expect(validateModule(mod).map((error) => error.message)).toContain( - `union mixed: ${type.kind} arm 0 beside non-unit arms`, - ); - }, -); +test("a maybe-promise union holds one promise arm beside data arms", () => { + const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ + { id: "maybe", arms: [{ kind: "promise", inner: F64 }, STRING, UNDEFINED_T] }, + ]); + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); +}); + +test("a union refuses a second promise arm", () => { + const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ + { + id: "mixed", + arms: [BOOL, { kind: "promise", inner: F64 }, { kind: "promise", inner: STRING }], + }, + ]); + expect(validateModule(mod).map((error) => error.message)).toContain( + "union mixed: promise arm 1 beside another promise arm", + ); +}); test("differently typed collection payloads preserve separate union tags", () => { const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 09ab7d951..73c819ed9 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -53,7 +53,7 @@ import { STRING, typeEquals, typeKey, - unionContainerArmsOk, + maybePromiseUnionArmsOk, VOID, } from "./ir.js"; import { DYN_CLASS_PROPERTIES } from "./ir.js"; @@ -821,7 +821,7 @@ export function validateModule(mod: IrModule): IrValidationError[] { u.arms.forEach((arm, i) => { // The unit kinds (undefinedT/nullT) are valid arms — union membership // is the ONLY place they may appear. Container payloads preserve - // identity; promise data siblings retain their separate fence. + // identity; a union carries at most one promise arm (maybe-promise). if ( arm.kind === "void" || arm.kind === "union" || @@ -831,9 +831,9 @@ export function validateModule(mod: IrModule): IrValidationError[] { ) { errors.push({ message: `union ${u.id}: arm ${i} is ${arm.kind}`, loc: noLoc }); } - if (arm.kind === "promise" && !unionContainerArmsOk(u.arms)) { + if (arm.kind === "promise" && !maybePromiseUnionArmsOk(u.arms)) { errors.push({ - message: `union ${u.id}: ${arm.kind} arm ${i} beside non-unit arms`, + message: `union ${u.id}: ${arm.kind} arm ${i} beside another promise arm`, loc: noLoc, }); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json new file mode 100644 index 000000000..87429c80b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6240-maybe-promise-await-order.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json new file mode 100644 index 000000000..3b5e1fbed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6241-maybe-promise-handlers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json new file mode 100644 index 000000000..b8bec7cac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6242-maybe-promise-rejections.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json new file mode 100644 index 000000000..555b5e987 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6243-maybe-promise-narrowing.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json new file mode 100644 index 000000000..275f06bef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6244-maybe-promise-async-return.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json new file mode 100644 index 000000000..d367e5eb5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6245-rest-unknown-handlers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json new file mode 100644 index 000000000..ee4654507 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6246-map-set-function-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json new file mode 100644 index 000000000..83a605ab0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6247-next-proxy-maybe-promise.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json new file mode 100644 index 000000000..e5cf59339 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6248-tagged-template-any-rest.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json new file mode 100644 index 000000000..c01942ee0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6249-tagged-template-missing-substitutions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json new file mode 100644 index 000000000..2e463b920 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/maybe-promise-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json deleted file mode 100644 index 2af7d599f..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json deleted file mode 100644 index 571eaa53d..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts" - ], - "diags": [] -} diff --git a/tests/corpus/6240-maybe-promise-await-order.ts b/tests/corpus/6240-maybe-promise-await-order.ts new file mode 100644 index 000000000..728278348 --- /dev/null +++ b/tests/corpus/6240-maybe-promise-await-order.ts @@ -0,0 +1,60 @@ +// `await` on a maybe-promise union (`T | Promise`): the promise arm +// resumes one turn after it settles, every other arm is a non-thenable that +// resumes after exactly one microtask turn with the value itself. A ticker +// chain interleaves with each await so the turn counts are observable. + +type MaybeString = string | Promise; + +const log: string[] = []; +let tick = 0; +function ticker(turns: number): void { + if (turns === 0) return; + Promise.resolve().then(() => { + tick++; + log.push("T" + tick); + ticker(turns - 1); + }); +} + +const settled: Promise = Promise.resolve("settled"); +function choose(kind: number): MaybeString { + if (kind === 0) return "plain"; + if (kind === 1) return settled; + // Settles two turns from now. + return new Promise((resolve) => { + Promise.resolve().then(() => resolve("late")); + }); +} + +async function awaitOnce(kind: number): Promise { + log.push("start" + kind); + const value = await choose(kind); + log.push("got" + kind + ":" + value); +} + +async function awaitTwice(): Promise { + const first = await choose(0); + const second = await choose(1); + log.push("twice:" + first + "+" + second); +} + +// A union with unit arms beside the data arm: `undefined` is a +// non-thenable too. +type MaybeCount = number | undefined | Promise; +function count(kind: number): MaybeCount { + return kind === 0 ? 7 : kind === 1 ? undefined : Promise.resolve(kind === 2 ? 9 : undefined); +} +async function counts(): Promise { + for (let kind = 0; kind < 4; kind++) { + const value = await count(kind); + log.push("count" + kind + ":" + String(value)); + } +} + +ticker(12); +awaitOnce(0); +awaitOnce(1); +awaitOnce(2); +awaitTwice(); +counts(); +setTimeout(() => console.log(log.join(" ")), 5); diff --git a/tests/corpus/6241-maybe-promise-handlers.ts b/tests/corpus/6241-maybe-promise-handlers.ts new file mode 100644 index 000000000..49549eaa4 --- /dev/null +++ b/tests/corpus/6241-maybe-promise-handlers.ts @@ -0,0 +1,77 @@ +// Handlers typed `Promise | R | void` (an extension event +// handler) and members typed `(p: string) => boolean | Promise` +// (a tool's filesystem operations): sync and async implementations share +// one slot, and the caller awaits whatever comes back. + +interface Ops { + exists: (absolutePath: string) => boolean | Promise; +} +const syncOps: Ops = { exists: (p) => p.length > 3 }; +const asyncOps: Ops = { exists: async (p) => p.startsWith("/") }; + +interface Result { + block: boolean; + reason: string; +} +type Handler = (n: number) => Promise | Result | void; + +const handlers: Handler[] = []; +handlers.push((n) => { + if (n > 5) return { block: true, reason: "sync big" }; +}); +handlers.push(async (n) => { + if (n === 3) return { block: true, reason: "async three" }; +}); +handlers.push((n) => (n === 4 ? Promise.resolve({ block: false, reason: "promise four" }) : undefined)); + +// Named functions flow into the slot through their declared types. +async function asyncNamed(n: number): Promise { + return { block: n === 2, reason: "async named" }; +} +function syncNamed(n: number): Result { + return { block: n === 1, reason: "sync named" }; +} +function voidNamed(n: number): void { + console.log("void handler saw", n); +} +async function asyncVoid(n: number): Promise { + console.log("async void handler saw", n); +} +const named: Handler[] = [asyncNamed, syncNamed, voidNamed, asyncVoid]; + +async function run(list: Handler[], n: number): Promise { + for (const h of list) { + const r = await h(n); + if (r && r.block) return r.reason; + } + return "none"; +} + +// A class-instance arm beside null and the promise arm. +class Response { + status: number; + constructor(status: number) { + this.status = status; + } +} +type Route = (path: string) => Response | null | Promise; +const routes: Route[] = [ + (path) => (path === "/sync" ? new Response(200) : null), + async (path) => (path === "/async" ? new Response(202) : null), +]; +async function route(path: string): Promise { + for (const r of routes) { + const res = await r(path); + if (res instanceof Response) return path + " " + res.status; + } + return path + " 404"; +} + +async function main(): Promise { + console.log(await syncOps.exists("/tmp/x"), await syncOps.exists("/a")); + console.log(await asyncOps.exists("/tmp/x"), await asyncOps.exists("tmp")); + for (const n of [1, 3, 4, 7]) console.log(n, await run(handlers, n)); + for (const n of [1, 2, 9]) console.log(n, await run(named, n)); + for (const path of ["/sync", "/async", "/missing"]) console.log(await route(path)); +} +main(); diff --git a/tests/corpus/6242-maybe-promise-rejections.ts b/tests/corpus/6242-maybe-promise-rejections.ts new file mode 100644 index 000000000..6323d6242 --- /dev/null +++ b/tests/corpus/6242-maybe-promise-rejections.ts @@ -0,0 +1,77 @@ +// Rejections through maybe-promise values: a rejected promise arm throws at +// the await, a synchronous throw never reaches the await, an async return +// adopts the promise arm (so its rejection settles the caller's promise +// after the function's own handlers), and a `Promise | void` +// callback's promise is adopted by an async function returning it. + +type MaybeBool = boolean | Promise; +function check(kind: number): MaybeBool { + if (kind === 0) return true; + if (kind === 1) return Promise.reject(new Error("rejected " + kind)); + if (kind === 2) throw new Error("thrown " + kind); + return Promise.resolve(false); +} + +async function awaitEach(): Promise { + for (let kind = 0; kind < 4; kind++) { + try { + console.log(kind, await check(kind)); + } catch (e) { + console.log(kind, "caught", (e as Error).message); + } + } +} + +// `return check(1)` inside try/catch/finally: JS completes the return first +// (the finally runs), and only then does the async function's promise adopt +// the rejected arm, so this function's own catch never sees it. +async function returnInTry(kind: number): Promise { + try { + return check(kind); + } catch (e) { + console.log("inner catch", (e as Error).message); + return false; + } finally { + console.log("finally", kind); + } +} + +type Callback = () => Promise | void; +const callbacks: Callback[] = [ + () => { + console.log("sync callback"); + }, + async () => { + throw new Error("async callback failed"); + }, +]; +async function runCallback(cb: Callback): Promise { + return cb(); +} + +async function main(): Promise { + await awaitEach(); + for (const kind of [0, 1, 2]) { + try { + console.log("returned", await returnInTry(kind)); + } catch (e) { + console.log("outer catch", (e as Error).message); + } + } + for (const cb of callbacks) { + try { + await runCallback(cb); + console.log("callback ok"); + } catch (e) { + console.log("callback caught", (e as Error).message); + } + } + // A then handler returning a rejected promise arm rejects the chain. + await Promise.resolve(1) + .then(() => check(1)) + .then( + (v) => console.log("then value", v), + (e: Error) => console.log("then rejected", e.message), + ); +} +main(); diff --git a/tests/corpus/6243-maybe-promise-narrowing.ts b/tests/corpus/6243-maybe-promise-narrowing.ts new file mode 100644 index 000000000..7c1fe6fd2 --- /dev/null +++ b/tests/corpus/6243-maybe-promise-narrowing.ts @@ -0,0 +1,61 @@ +// Telling the arms of a maybe-promise union apart: `instanceof Promise`, +// the `then` probe through a cast, typeof, truthiness, equality, and +// Promise.resolve (which hands a promise back as is and wraps a value). + +type MaybeString = string | Promise; +function make(i: number): MaybeString { + return i % 2 === 1 ? "s" + i : Promise.resolve("p" + i); +} + +interface Box { + v: number; +} +type MaybeBox = Box | Promise; +function box(i: number): MaybeBox { + return i % 2 === 0 ? { v: i } : Promise.resolve({ v: i * 10 }); +} +function isThenable(x: MaybeBox): boolean { + return typeof (x as any).then === "function"; +} + +type Slot = { name: string; value: string | Promise | null }; +const slots: Slot[] = [ + { name: "a", value: "x" }, + { name: "b", value: Promise.resolve("y") }, + { name: "c", value: null }, +]; + +async function main(): Promise { + for (let i = 0; i < 4; i++) { + const v = make(i); + if (v instanceof Promise) console.log(i, "promise", await v); + else console.log(i, "value", v.toUpperCase()); + console.log(typeof v); + } + for (let i = 0; i < 3; i++) { + const b = box(i); + console.log(i, isThenable(b), typeof (b as Promise).then); + if ((b as Promise).then) console.log("has then"); + console.log((await b).v); + } + for (const s of slots) { + const v = await s.value; + console.log(s.name, v === null ? "null" : v, s.value === null, s.value instanceof Promise, typeof s.value); + if (s.value) console.log("truthy", s.name); + } + console.log(slots[1]!.value === slots[1]!.value); + + // Promise.resolve keeps a promise's identity and wraps a value. + const p = Promise.resolve(7); + const get = (b: boolean): number | Promise => (b ? p : 3); + const fromPromise = Promise.resolve(get(true)); + const fromValue = Promise.resolve(get(false)); + console.log(fromPromise === p, fromValue === p); + const order: string[] = []; + fromPromise.then((v) => order.push("promise" + v)); + fromValue.then((v) => order.push("value" + v)); + Promise.resolve().then(() => order.push("other")); + await new Promise((resolve) => setTimeout(resolve, 1)); + console.log(order.join(",")); +} +main(); diff --git a/tests/corpus/6244-maybe-promise-async-return.ts b/tests/corpus/6244-maybe-promise-async-return.ts new file mode 100644 index 000000000..9c38a8f7d --- /dev/null +++ b/tests/corpus/6244-maybe-promise-async-return.ts @@ -0,0 +1,61 @@ +// Returning a maybe-promise value from async code flattens it: the +// promise arm is adopted (the extra then-job turns of a returned promise), +// any other arm fulfills directly. Covers block bodies, concise arrow +// bodies, `return await`, and then handlers. + +type MaybeString = string | Promise; +const settled: Promise = Promise.resolve("settled"); +function choose(kind: number): MaybeString { + if (kind === 0) return "plain"; + if (kind === 1) return settled; + return new Promise((resolve) => setTimeout(() => resolve("timer"), 1)); +} + +async function viaReturn(kind: number): Promise { + return choose(kind); +} +async function viaReturnAwait(kind: number): Promise { + return await choose(kind); +} +const viaArrow = async (kind: number): Promise => choose(kind); + +const log: string[] = []; +let tick = 0; +function ticker(turns: number): void { + if (turns === 0) return; + Promise.resolve().then(() => { + tick++; + log.push("T" + tick); + ticker(turns - 1); + }); +} + +// A then handler returning a maybe-promise value: a cache that answers +// synchronously once loaded. +const cache = new Map(); +function load(key: string): string | Promise { + const hit = cache.get(key); + if (hit !== undefined) return hit; + return new Promise((resolve) => + setTimeout(() => { + cache.set(key, key.toUpperCase()); + resolve(key.toUpperCase()); + }, 1), + ); +} + +ticker(10); +for (let kind = 0; kind < 3; kind++) { + viaReturn(kind).then((v) => log.push("ret" + kind + ":" + v)); + viaReturnAwait(kind).then((v) => log.push("await" + kind + ":" + v)); + viaArrow(kind).then((v) => log.push("arrow" + kind + ":" + v)); +} +const start = Promise.resolve("k"); +start + .then((key) => load(key)) + .then((v) => { + log.push("first:" + v); + return start.then((key) => load(key)); + }) + .then((v) => log.push("second:" + v)); +setTimeout(() => console.log(log.join(" ")), 20); diff --git a/tests/corpus/6245-rest-unknown-handlers.ts b/tests/corpus/6245-rest-unknown-handlers.ts new file mode 100644 index 000000000..664c6160d --- /dev/null +++ b/tests/corpus/6245-rest-unknown-handlers.ts @@ -0,0 +1,46 @@ +// Rest parameters typed `unknown[]` or `any[]` in function types: the +// handler registry of an extension host, with forwarding wrappers and +// spread calls. + +type HandlerFn = (...args: unknown[]) => Promise; +const handlers = new Map(); +function on(event: string, handler: HandlerFn): () => void { + const registered: HandlerFn = (...args) => handler(...args); + const list = handlers.get(event) ?? []; + list.push(registered); + handlers.set(event, list); + return () => { + const current = handlers.get(event); + if (!current) return; + const index = current.indexOf(registered); + if (index !== -1) current.splice(index, 1); + }; +} +async function emit(event: string, ...args: unknown[]): Promise { + const out: unknown[] = []; + for (const h of handlers.get(event) ?? []) out.push(await h(...args)); + return out; +} + +on("x", async (...args) => args.length); +const off = on("x", async (...args: unknown[]) => typeof args[0] + ":" + String(args[1])); +on("y", async (...args) => "no args " + args.length); + +function sum(...nums: unknown[]): number { + let total = 0; + for (const n of nums) if (typeof n === "number") total += n; + return total; +} +const stored: (...values: unknown[]) => number = sum; +const logAll: (...values: any[]) => void = (...values: any[]) => console.log(values.length, values[0]); + +async function main(): Promise { + console.log(JSON.stringify(await emit("x", 1, "two", true))); + off(); + console.log(JSON.stringify(await emit("x", 5))); + console.log(JSON.stringify(await emit("y"))); + console.log(sum(1, 2, "x", 4), stored(5, 6), stored()); + logAll("p", "q"); + logAll(); +} +main(); diff --git a/tests/corpus/6246-map-set-function-values.ts b/tests/corpus/6246-map-set-function-values.ts new file mode 100644 index 000000000..fc9aa187d --- /dev/null +++ b/tests/corpus/6246-map-set-function-values.ts @@ -0,0 +1,52 @@ +// Functions as Map values, Map keys and Set elements: factory and listener +// registries keep each closure's identity, and a handler that captures its +// own registry is collected with it. + +interface Extension { + name: string; + run(x: number): number; +} +type ExtensionFactory = (api: { log: (s: string) => void }) => Extension; +const factories = new Map(); +factories.set("double", (api) => ({ + name: "double", + run: (x) => { + api.log("double " + x); + return x * 2; + }, +})); +factories.set("inc", () => ({ name: "inc", run: (x) => x + 1 })); +const lines: string[] = []; +const api = { log: (s: string) => lines.push(s) }; +for (const [key, make] of factories) { + const ext = make(api); + console.log(key, ext.name, ext.run(10)); +} +console.log(factories.get("missing") === undefined, typeof factories.get("inc"), factories.has("inc")); +const inc = factories.get("inc"); +if (inc) console.log(inc(api).run(41)); +factories.delete("inc"); +console.log([...factories.keys()].join(","), factories.size, lines.join(";")); + +type Listener = (v: string) => void; +const listeners = new Set(); +const a: Listener = (v) => console.log("a", v); +const b: Listener = (v) => console.log("b", v); +listeners.add(a); +listeners.add(b); +listeners.add(a); +console.log(listeners.size, listeners.has(a), listeners.has((v) => console.log(v))); +for (const l of listeners) l("x"); +listeners.delete(a); +listeners.forEach((l) => l("y")); + +const counts = new Map(); +counts.set(a, 1); +counts.set(b, 2); +counts.set(a, 3); +console.log(counts.size, counts.get(a), counts.get(b)); + +const registry = new Map number>(); +registry.set("self", () => registry.size); +registry.set("double", () => registry.size * 2); +console.log([...registry.values()].map((f) => f()).join(",")); diff --git a/tests/corpus/6247-next-proxy-maybe-promise.ts b/tests/corpus/6247-next-proxy-maybe-promise.ts new file mode 100644 index 000000000..264742e2b --- /dev/null +++ b/tests/corpus/6247-next-proxy-maybe-promise.ts @@ -0,0 +1,35 @@ +// The Next.js proxy signature: `(request, event) => NextMiddlewareResult | +// Promise` where the result is a response class, null, +// undefined or void. Sync and async proxies run through one host. + +class NextResponse { + status: number; + constructor(status: number) { + this.status = status; + } +} +type NextMiddlewareResult = NextResponse | null | undefined | void; +type NextProxy = (request: string, event: number) => NextMiddlewareResult | Promise; + +const proxy: NextProxy = (request) => { + if (request === "/health") return new NextResponse(200); + if (request === "/async") return Promise.resolve(new NextResponse(202)); + if (request === "/null") return null; +}; +const asyncProxy: NextProxy = async (request) => (request === "/a" ? new NextResponse(201) : undefined); + +async function handle(p: NextProxy, request: string): Promise { + const result = await p(request, 0); + return result ? String(result.status) : String(result); +} + +// The repro shape: Promise.resolve over the handler's result. +type Handler = (n: number) => NextResponse | null | Promise; +const h: Handler = () => new NextResponse(204); +Promise.resolve(h(1)).then((r) => console.log(r ? r.status : "none")); + +async function main(): Promise { + for (const request of ["/health", "/async", "/null", "/other"]) console.log(request, await handle(proxy, request)); + for (const request of ["/a", "/b"]) console.log(request, await handle(asyncProxy, request)); +} +main(); diff --git a/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts b/tests/corpus/6248-tagged-template-any-rest.ts similarity index 100% rename from tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts rename to tests/corpus/6248-tagged-template-any-rest.ts diff --git a/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts b/tests/corpus/6249-tagged-template-missing-substitutions.ts similarity index 100% rename from tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts rename to tests/corpus/6249-tagged-template-missing-substitutions.ts diff --git a/tests/diagnostics/component-fences.ts b/tests/diagnostics/component-fences.ts index 267102086..6d88f9af5 100644 --- a/tests/diagnostics/component-fences.ts +++ b/tests/diagnostics/component-fences.ts @@ -5,9 +5,9 @@ const byFlag = new Map(); console.log(byFlag); -// Map values have no slot for functions. -const listeners = new Map void>(); -console.log(listeners); +// Map values have no slot for Dates. +const stamps = new Map(); +console.log(stamps); // Boolean Set elements remain outside the supported element domain. const flags = new Set(); diff --git a/tests/diagnostics/maps.ts b/tests/diagnostics/maps.ts index eaa2508d3..fcdef411d 100644 --- a/tests/diagnostics/maps.ts +++ b/tests/diagnostics/maps.ts @@ -4,7 +4,7 @@ // Boolean keys compile alongside the remaining value and method fences. const byFlag = new Map(); -// Values exclude functions (no closure story in the uniform value slot yet). +// Function values compile (closures keep their identity in the slot). const handlers = new Map void>(); // Typed nested Maps retain their identity and are supported. diff --git a/tests/diagnostics/maybe-promise-fences.ts b/tests/diagnostics/maybe-promise-fences.ts new file mode 100644 index 000000000..efe541c9e --- /dev/null +++ b/tests/diagnostics/maybe-promise-fences.ts @@ -0,0 +1,37 @@ +// Maybe-promise unions (`T | Promise`) compile when the union tag can +// answer what JS's await decides. These forms stay refused. + +// A data arm that declares `then` is a thenable: await would adopt it. +interface Thenish { + then(cb: (v: number) => void): void; +} +function thenable(): Thenish | Promise { + return Promise.resolve(1); +} +console.log(typeof thenable); + +// Two promise arms: the union tag cannot tell them apart by instanceof. +function twoPromises(): Promise | Promise | boolean { + return true; +} +console.log(typeof twoPromises); + +// Promises have no inspect lowering, so neither does a union holding one. +function maybe(b: boolean): string | Promise { + return b ? "s" : Promise.resolve("p"); +} +console.log(maybe(true)); + +// Promise.resolve hands the promise arm back as is, so the result type +// must be that arm's own promise type. +const widened = Promise.resolve(maybe(false)); +console.log(typeof widened); + +// `instanceof Promise` on a value that is never a promise. +const record = { v: 1 }; +console.log(record instanceof Promise); + +// A variadic `unknown[]` slot takes closures with the same variadic ABI. +type HandlerFn = (...args: unknown[]) => Promise; +const fixed: HandlerFn = async (first: unknown) => first; +fixed(1, 2).then((v) => console.log(v)); diff --git a/tests/harness/__snapshots__/component-fences.ts.txt b/tests/harness/__snapshots__/component-fences.ts.txt index 498846004..5ed6ad6b9 100644 --- a/tests/harness/__snapshots__/component-fences.ts.txt +++ b/tests/harness/__snapshots__/component-fences.ts.txt @@ -1,27 +1,27 @@ -component-fences.ts:9:7 - error SC2009: values of type 'Map void>' cannot be compiled: the Map shape is supported, but '() => void' values have no Map slot yet +component-fences.ts:9:7 - error SC2009: values of type 'Map' cannot be compiled: the Map shape is supported, but 'Date' values have no Map slot yet - 8 | // Map values have no slot for functions. - 9 | const listeners = new Map void>(); - | ^~~~~~~~~ - 10 | console.log(listeners); + 8 | // Map values have no slot for Dates. + 9 | const stamps = new Map(); + | ^~~~~~ + 10 | console.log(stamps); hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -component-fences.ts:9:19 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet +component-fences.ts:9:16 - error SC1090: Map values of type 'Date' (Map values must be number, string, boolean, records, class instances, functions, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet - 8 | // Map values have no slot for functions. - 9 | const listeners = new Map void>(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 10 | console.log(listeners); + 8 | // Map values have no slot for Dates. + 9 | const stamps = new Map(); + | ^~~~~~~~~~~~~~~~~~~~~~~ + 10 | console.log(stamps); -component-fences.ts:10:13 - error SC2004: uses of 'listeners' inherit the blocker on its declaration +component-fences.ts:10:13 - error SC2004: uses of 'stamps' inherit the blocker on its declaration - 9 | const listeners = new Map void>(); - 10 | console.log(listeners); - | ^~~~~~~~~ + 9 | const stamps = new Map(); + 10 | console.log(stamps); + | ^~~~~~ 11 | - hint: the declaration of 'listeners' did not compile — fix the diagnostic reported there and these sites clear with it + hint: the declaration of 'stamps' did not compile — fix the diagnostic reported there and these sites clear with it component-fences.ts:35:7 - error SC2009: values of type '() => WeakRef' cannot be compiled: the function shape is supported, but its return type 'WeakRef' does not compile diff --git a/tests/harness/__snapshots__/maps.ts.txt b/tests/harness/__snapshots__/maps.ts.txt index fe64f3787..7d02a345c 100644 --- a/tests/harness/__snapshots__/maps.ts.txt +++ b/tests/harness/__snapshots__/maps.ts.txt @@ -1,19 +1,3 @@ -maps.ts:8:7 - error SC2009: values of type 'Map void>' cannot be compiled: the Map shape is supported, but '() => void' values have no Map slot yet - - 7 | // Values exclude functions (no closure story in the uniform value slot yet). - 8 | const handlers = new Map void>(); - | ^~~~~~~~ - 9 | - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -maps.ts:8:18 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet - - 7 | // Values exclude functions (no closure story in the uniform value slot yet). - 8 | const handlers = new Map void>(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 9 | - maps.ts:29:28 - error SC1090: JSON.stringify of 'Map' values (only number, string, boolean, records, arrays, unions of those, and 'unknown' stringify) are not supported yet 28 | const m = new Map(); diff --git a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt new file mode 100644 index 000000000..58fb23517 --- /dev/null +++ b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt @@ -0,0 +1,65 @@ +maybe-promise-fences.ts:8:10 - error SC2009: values of type 'Promise | Thenish' cannot be compiled: the union shape is supported, but its arm 'Thenish' declares 'then' beside a promise arm, so 'await' would have to adopt it as a thenable (a maybe-promise union's data arms must not be thenable) + + 7 | } + 8 | function thenable(): Thenish | Promise { + | ^~~~~~~~ + 9 | return Promise.resolve(1); + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blocker on its declaration + + 10 | } + 11 | console.log(typeof thenable); + | ^~~~~~~~ + 12 | + + hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it + +maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise | Promise' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise' and 'Promise'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test) + + 13 | // Two promise arms: the union tag cannot tell them apart by instanceof. + 14 | function twoPromises(): Promise | Promise | boolean { + | ^~~~~~~~~~~ + 15 | return true; + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration + + 16 | } + 17 | console.log(typeof twoPromises); + | ^~~~~~~~~~~ + 18 | + + hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it + +maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise | string' values ('Promise' values have no inspect lowering) are not supported yet + + 22 | } + 23 | console.log(maybe(true)); + | ^~~~~~~~~~~ + 24 | + +maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise | string' at this result type' is part of the standard library types but has no scriptc lowering yet + + 26 | // must be that arm's own promise type. + 27 | const widened = Promise.resolve(maybe(false)); + | ^~~~~~~~~~~~ + 28 | console.log(typeof widened); + + hint: the promise arm passes through identically, so it must already be the result's promise type + +maybe-promise-fences.ts:32:13 - error SC1090: 'instanceof Promise' on '{ v: number }' values (only promises, promise-armed unions, and checked-dynamic values have a lowering) are not supported yet + + 31 | const record = { v: 1 }; + 32 | console.log(record instanceof Promise); + | ^~~~~~~~~~~~~~~~~~~~~~~~~ + 33 | + +maybe-promise-fences.ts:36:26 - error SC1090: '(unknown) => Promise' values where '() => Promise' is expected is not supported yet + + 35 | type HandlerFn = (...args: unknown[]) => Promise; + 36 | const fixed: HandlerFn = async (first: unknown) => first; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 37 | fixed(1, 2).then((v) => console.log(v)); \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt b/tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt deleted file mode 100644 index 8d2f13b3e..000000000 --- a/tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt +++ /dev/null @@ -1,44 +0,0 @@ -retired-engine-2251-tagged-templates-dyn.ts:6:15 - error SC2009: values of type 'any[]' cannot be compiled: the array shape is supported, but its element type 'any' does not compile - - 5 | // non-function tag throws Node's catchable TypeError). - 6 | function f(...args: any[]): string { - | ^~~~ - 7 | return args.length > 1 - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -retired-engine-2251-tagged-templates-dyn.ts:11:13 - error SC2004: uses of 'f' inherit the blocker on its declaration - - 10 | } - 11 | console.log(f`a${1}b${2}c`); - | ^ - 12 | console.log(f`plain`); - - hint: the declaration of 'f' did not compile — fix the diagnostic reported there and these sites clear with it - -retired-engine-2251-tagged-templates-dyn.ts:12:13 - error SC2004: uses of 'f' inherit the blocker on its declaration - - 11 | console.log(f`a${1}b${2}c`); - 12 | console.log(f`plain`); - | ^ - 13 | - - hint: the declaration of 'f' did not compile — fix the diagnostic reported there and these sites clear with it - -retired-engine-2251-tagged-templates-dyn.ts:14:29 - error SC2009: values of type 'any[]' cannot be compiled: the array shape is supported, but its element type 'any' does not compile - - 13 | - 14 | function declare(x: any, ...ys: any[]): string { - | ^~ - 15 | return `${String(x)}|${ys.length}`; - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -retired-engine-2251-tagged-templates-dyn.ts:17:13 - error SC2004: uses of 'declare' inherit the blocker on its declaration - - 16 | } - 17 | console.log(declare`Hello ${0} world!`); - | ^~~~~~~ - 18 | - - hint: the declaration of 'declare' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt b/tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt deleted file mode 100644 index 6f03a0bfc..000000000 --- a/tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt +++ /dev/null @@ -1,17 +0,0 @@ -retired-engine-2790-array-missing-tagged-substitutions.ts:11:25 - error SC2009: values of type 'any[]' cannot be compiled: the array shape is supported, but its element type 'any' does not compile - - 10 | - 11 | function dynamicRest(...args: any[]): string { - | ^~~~ - 12 | return `${args.length}:${String(args[1])}`; - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -retired-engine-2790-array-missing-tagged-substitutions.ts:14:13 - error SC2004: uses of 'dynamicRest' inherit the blocker on its declaration - - 13 | } - 14 | console.log(dynamicRest`a${values[9]}b`); - | ^~~~~~~~~~~ - 15 | - - hint: the declaration of 'dynamicRest' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file From 05b63df116c9ee10091f75656b96b56b630f3e1f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 18:05:09 -0500 Subject: [PATCH 09/12] Check Buffer.concat's length before the elements of an untyped list Node's Buffer.concat(list, length) validates length (after returning early for an empty list) before it checks that every element is a Uint8Array. The untyped-list path checked the elements first, so a bad element and a bad length threw ERR_INVALID_ARG_TYPE where Node throws the length's ERR_OUT_OF_RANGE. --- packages/runtime/src/scr_bytes_io.c | 12 ++++++++---- tests/corpus/5973-buffer-concat-untyped.js | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index fc7b3bbb8..4d7ef5f67 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1249,10 +1249,10 @@ static ScrBytes *scr_buffer_from_array_like(const ScrDyn *value, double length) return out; } -/* Buffer.concat over an untyped list: Node's checks (an Array whose every - * element is a Uint8Array, ERR_INVALID_ARG_TYPE otherwise), then the typed - * concat over the elements. total < 0 selects the one-argument form. NULL - * with the exception pending. */ +/* Buffer.concat over an untyped list: Node's checks in Node's order (an + * Array, then the total of the two-argument form unless the list is empty, + * then every element a Uint8Array, ERR_INVALID_ARG_TYPE otherwise), then the + * typed concat over the elements. NULL with the exception pending. */ static ScrBytes *scr_buffer_concat_dyn_impl(const ScrDyn *list, double total, bool has_total) { ScrDyn *materialized = NULL; if (list->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(list) != NULL) { @@ -1265,6 +1265,10 @@ static ScrBytes *scr_buffer_concat_dyn_impl(const ScrDyn *list, double total, bo return NULL; } size_t n = list->v.arr.len; + if (has_total && n > 0 && !scr_bytes_validate_off("length", total, 9007199254740991.0)) { + scr_dyn_release(materialized); + return NULL; + } ScrArr *parts = scr_arr_new(SCR_ELEM_BYTES, (double)n); for (size_t i = 0; i < n; i++) { ScrDyn *item = scr_dyn_index_get(list, (double)i); diff --git a/tests/corpus/5973-buffer-concat-untyped.js b/tests/corpus/5973-buffer-concat-untyped.js index 54256c935..116ca0a2b 100644 --- a/tests/corpus/5973-buffer-concat-untyped.js +++ b/tests/corpus/5973-buffer-concat-untyped.js @@ -1,6 +1,8 @@ // Buffer.concat over an untyped list (JavaScript's `const chunks = []`): // Uint8Array elements concatenate, the length form pads or truncates, and -// a non-array list or a non-Uint8Array element throws Node's TypeError. +// a non-array list or a non-Uint8Array element throws Node's TypeError. The +// length form checks the length before the elements, and an empty list +// returns before either. const chunks = []; chunks.push(Buffer.from("ab")); chunks.push(new Uint8Array([99, 100])); @@ -18,3 +20,19 @@ for (const list of lists) { console.log(e.name, e.code, e.message); } } + +const withLength = [ + [[1], -1], + [[Buffer.from("a"), "b"], NaN], + [[1], 2], + [[], -1], + [[Buffer.from("a")], 1.5], + [[Buffer.from("a"), Buffer.from("b")], 2], +]; +for (const [list, length] of withLength) { + try { + console.log("length", Buffer.concat(list, length).length); + } catch (e) { + console.log(e.name, e.code, e.message); + } +} From 8d94e5d99396a2a1fac9e949708a9aee56b2a12e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 18:34:09 -0500 Subject: [PATCH 10/12] Refuse RegExp and Promise class fields in JSON.stringify A Date field reads through the class's checked-dynamic property view and writes its toJSON text, so json-class-fields.ts compiled and matched Node while the diagnostics test expected a refusal. The Date cases move to 6000-json-stringify-class-instances.ts. RegExp and Promise fields read through the view but the runtime JSON traversal has no writer for them: a RegExp field threw at run time where Node writes {}, and a Promise field failed to link. Both are now refused at compile time naming the field, also inside a field's arrays, records and unions, and the fixture covers them. --- .../language-features/class-reflection.mdx | 2 +- .../src/frontend/lowering/builtins/json.ts | 50 ++++++++++++++++--- .../6000-json-stringify-class-instances.ts | 12 +++++ tests/diagnostics/json-class-fields.ts | 25 ++++++---- .../__snapshots__/json-class-fields.ts.txt | 27 ++++++---- 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/content/limitations/language-features/class-reflection.mdx b/docs/content/limitations/language-features/class-reflection.mdx index 8b0738cc7..f2b6dc2c8 100644 --- a/docs/content/limitations/language-features/class-reflection.mdx +++ b/docs/content/limitations/language-features/class-reflection.mdx @@ -1 +1 @@ -- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Map and Set fields as `{}`, as Node.js does, and refuses classes with Date fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. +- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Date fields as their ISO text and Map and Set fields as `{}`, as Node.js does, and refuses classes with RegExp or Promise fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index c99a99c75..013b16e7c 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -168,7 +168,7 @@ export function lowerJsonMethodCall( argNode, `JSON.stringify of '${lowerer.fmt(value.type)}' values (field '${unreadable.field}' of class ` + `'${unreadable.className}' has type '${lowerer.fmt(unreadable.type)}', which the runtime JSON ` + - `traversal cannot read)`, + `traversal cannot ${unreadable.reason})`, ); } // Keep the original containers live while the runtime visits them: @@ -290,15 +290,47 @@ function lowerOptionalStringifyRoot( return { kind: "call", callee: helper, args: [value], type: resultT, loc }; } +/** True when a class field type holds a RegExp or a Promise outside a + * nested class instance (whose own fields are checked separately): Node + * writes both as {}, and the runtime JSON traversal has no writer for them. */ +function holdsUnwritable(lowerer: Lowerer, t: IrType, visiting: Set): boolean { + switch (t.kind) { + case "regex": + case "promise": + return true; + case "array": + return holdsUnwritable(lowerer, t.elem, visiting); + case "record": { + if (visiting.has(t.shapeId)) return false; + visiting.add(t.shapeId); + const shape = lowerer.shapes.get(t.shapeId); + return ( + (shape?.fields ?? []).some((field) => holdsUnwritable(lowerer, field.type, visiting)) || + (shape?.indexValue !== undefined && holdsUnwritable(lowerer, shape.indexValue, visiting)) + ); + } + case "union": { + if (visiting.has(t.unionId)) return false; + visiting.add(t.unionId); + return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => + holdsUnwritable(lowerer, arm, visiting), + ); + } + default: + return false; + } +} + /** The first class field reachable from a stringify input that the class's - * checked-dynamic property view cannot represent. The runtime JSON traversal + * checked-dynamic property view cannot represent, or that holds a RegExp or + * a Promise, which the runtime JSON traversal cannot write. The traversal * reads class instances through that view, so such a class is refused at - * compile time instead of throwing when the view is built. */ + * compile time instead of throwing when the view is built or written. */ function classFieldWithoutJsonView( lowerer: Lowerer, t: IrType, visiting: Set, -): { className: string; field: string; type: IrType } | null { +): { className: string; field: string; type: IrType; reason: "read" | "write" } | null { const getRecord = (id: string) => lowerer.shapes.get(id); const getUnion = (id: string) => lowerer.unions.get(id); switch (t.kind) { @@ -331,10 +363,16 @@ function classFieldWithoutJsonView( const info = lowerer.classes.get(t.className); for (const field of info?.def.fields ?? []) { if (!isClassOwnEnumerableFieldName(field.name)) continue; - if (!classDynViewSupported([field], getRecord, getUnion)) { + const unreadable = !classDynViewSupported([field], getRecord, getUnion); + if (unreadable || holdsUnwritable(lowerer, field.type, new Set())) { const className = info?.def.jsName ?? info?.decl?.name?.text ?? t.className.replace(/^%/, ""); - return { className, field: field.name, type: field.type }; + return { + className, + field: field.name, + type: field.type, + reason: unreadable ? "read" : "write", + }; } const found = classFieldWithoutJsonView(lowerer, field.type, visiting); if (found) return found; diff --git a/tests/corpus/6000-json-stringify-class-instances.ts b/tests/corpus/6000-json-stringify-class-instances.ts index a73168b40..cc72a0b82 100644 --- a/tests/corpus/6000-json-stringify-class-instances.ts +++ b/tests/corpus/6000-json-stringify-class-instances.ts @@ -129,3 +129,15 @@ h.payload = 7; console.log(JSON.stringify({ handlers: [h] })); const u: unknown = new Map([["a", 1]]); console.log(JSON.stringify(new Tagged()), JSON.stringify(u)); + +// Date fields write their toJSON text, also in nested instances. +class Schedule { + name = "weekly"; + next = new Date(Date.UTC(2024, 0, 1)); +} +class Calendar { + title = "team"; + schedules: Schedule[] = [new Schedule()]; +} +console.log(JSON.stringify(new Schedule())); +console.log(JSON.stringify({ calendar: new Calendar() })); diff --git a/tests/diagnostics/json-class-fields.ts b/tests/diagnostics/json-class-fields.ts index 81012c161..d4d3f8e73 100644 --- a/tests/diagnostics/json-class-fields.ts +++ b/tests/diagnostics/json-class-fields.ts @@ -1,15 +1,22 @@ // JSON.stringify reads class instances through their checked-dynamic -// property view. A class whose declared field has no representation in -// that view is refused at compile time, naming the field. -class Schedule { - name = "weekly"; - next = new Date(Date.UTC(2024, 0, 1)); +// property view. A class whose declared field holds a RegExp or a Promise, +// which the runtime JSON traversal cannot write, is refused at compile +// time, naming the field. +class Pattern { + name = "digits"; + source = /\d+/g; } -class Calendar { +class Job { + title = "fetch"; + pending = Promise.resolve(1); +} + +class Catalog { title = "team"; - schedules: Schedule[] = [new Schedule()]; + patterns: Pattern[] = [new Pattern()]; } -console.log(JSON.stringify(new Schedule())); -console.log(JSON.stringify({ calendar: new Calendar() })); +console.log(JSON.stringify(new Pattern())); +console.log(JSON.stringify(new Job())); +console.log(JSON.stringify({ catalog: new Catalog() })); diff --git a/tests/harness/__snapshots__/json-class-fields.ts.txt b/tests/harness/__snapshots__/json-class-fields.ts.txt index 568cfa47f..5e6c30fdf 100644 --- a/tests/harness/__snapshots__/json-class-fields.ts.txt +++ b/tests/harness/__snapshots__/json-class-fields.ts.txt @@ -1,13 +1,20 @@ -json-class-fields.ts:14:28 - error SC1090: JSON.stringify of 'Schedule' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet +json-class-fields.ts:20:28 - error SC1090: JSON.stringify of 'Pattern' values (field 'source' of class 'Pattern' has type 'RegExp', which the runtime JSON traversal cannot write) are not supported yet - 13 | - 14 | console.log(JSON.stringify(new Schedule())); - | ^~~~~~~~~~~~~~ - 15 | console.log(JSON.stringify({ calendar: new Calendar() })); + 19 | + 20 | console.log(JSON.stringify(new Pattern())); + | ^~~~~~~~~~~~~ + 21 | console.log(JSON.stringify(new Job())); -json-class-fields.ts:15:28 - error SC1090: JSON.stringify of '{ calendar: Calendar }' values (field 'next' of class 'Schedule' has type 'Date', which the runtime JSON traversal cannot read) are not supported yet +json-class-fields.ts:21:28 - error SC1090: JSON.stringify of 'Job' values (field 'pending' of class 'Job' has type 'Promise', which the runtime JSON traversal cannot write) are not supported yet - 14 | console.log(JSON.stringify(new Schedule())); - 15 | console.log(JSON.stringify({ calendar: new Calendar() })); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 16 | \ No newline at end of file + 20 | console.log(JSON.stringify(new Pattern())); + 21 | console.log(JSON.stringify(new Job())); + | ^~~~~~~~~ + 22 | console.log(JSON.stringify({ catalog: new Catalog() })); + +json-class-fields.ts:22:28 - error SC1090: JSON.stringify of '{ catalog: Catalog }' values (field 'source' of class 'Pattern' has type 'RegExp', which the runtime JSON traversal cannot write) are not supported yet + + 21 | console.log(JSON.stringify(new Job())); + 22 | console.log(JSON.stringify({ catalog: new Catalog() })); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~ + 23 | \ No newline at end of file From 6a2d43b462f8171f888c91f247981a919709d496 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:16:44 -0700 Subject: [PATCH 11/12] Keep declaration-file rest callbacks on their existing paths Library callback slots such as String#replace's (substring, ...args: any[]) have intrinsic lowerings that inspect the unmapped function type, so only rest parameters declared in program source take the checked-dynamic variadic ABI. Found by the zero-cost gate on corpus 6165 after merging main. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 236b2a6c0..1aa5b1d1b 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4317,11 +4317,16 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null if (!armed) return null; pt = armed; } - // `...args: unknown[]` / `...args: any[]`: the checked-dynamic - // variadic ABI (no typed slot; the callee's hidden trailing dyn - // array receives every surplus argument, as for JS rest params). + // `...args: unknown[]` / `...args: any[]` declared in program source: + // the checked-dynamic variadic ABI (no typed slot; the callee's hidden + // trailing dyn array receives every surplus argument, as for JS rest + // params). Declaration files keep their existing paths: library + // callback slots such as String#replace's `(substring, ...args: any[])` + // have intrinsic lowerings that inspect the unmapped type. if ( rest && + decl !== undefined && + !decl.getSourceFile().isDeclarationFile && (pt?.kind === "dyn" || (!pt && isAnyArrayType(checker.getTypeOfSymbol(p), checker))) ) { dynRest = true; From ec79cf27897af17e5ed035b9bee9c17ef6a0be9b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 19:42:27 -0500 Subject: [PATCH 12/12] Run iterator helpers on untyped iterators In JavaScript, a receiver without an inferred type, such as an untyped parameter's xs.values(), is the runtime's checked iterator. Its only method was next(), so xs.values().filter(f), .map(f) and the other iterator helpers threw "TypeError: ... is not a function" where Node runs them, and [].values().reduce(f) threw the same instead of Node's "Reduce of a done iterator with no initial value". The native array, string, Map and Set iterators now answer the Iterator.prototype methods. map, filter, take, drop and flatMap make helper objects (SCR_DYNH_ITERATOR_HELPER) that pull lazily and support next(), return(), for...of and spreads, print as "Object [Iterator Helper] {}" and tag as "[object Iterator Helper]". toArray, forEach, reduce, some, every and find drive the iterator. The helpers close the iterator they were made from where Node does: when a callback throws, when take's budget is spent, on return(), and before the error for an invalid argument. flatMap closes its inner iterator first. An error from a close that runs because of another error is dropped. Invalid arguments throw Node's messages, such as "number 5 is not a function" and "x must be positive". --- .../6070-iterator-helpers-untyped.js.json | 6 + packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_json.c | 691 +++++++++++++++++- tests/corpus/6070-iterator-helpers-untyped.js | 89 +++ tests/harness/corpus-costs.json | 2 + 5 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json create mode 100644 tests/corpus/6070-iterator-helpers-untyped.js diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json new file mode 100644 index 000000000..41ecbaab7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6070-iterator-helpers-untyped.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index ca21a0685..bccd8d55c 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -173,6 +173,7 @@ typedef enum { SCR_DYNH_BROADCAST_CHANNEL, /* worker_threads BroadcastChannel (scr_worker_events.c) */ SCR_DYNH_HASH, /* node:crypto Hash (scr_lib.c) */ SCR_DYNH_HMAC, /* node:crypto Hmac (scr_lib.c) */ + SCR_DYNH_ITERATOR_HELPER, /* an iterator helper object over a checked iterator (scr_json.c) */ SCR_DYNH_COUNT, } ScrDynHandleTag; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 0567d0b15..401933904 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -75,7 +75,7 @@ static bool scr_builtin_iterable(const ScrDyn *value) { return value->kind == SCR_DYN_ARR || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BYTES || (scr_dyn_generator(value) && value->v.typed_ref.type_key[0] != 'a') || (value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value)) || - (value->kind == SCR_DYN_HANDLE && (value->v.handle.tag == SCR_DYNH_ITERATOR || value->v.handle.tag == SCR_DYNH_MAP || value->v.handle.tag == SCR_DYNH_SET || + (value->kind == SCR_DYN_HANDLE && (value->v.handle.tag == SCR_DYNH_ITERATOR || value->v.handle.tag == SCR_DYNH_ITERATOR_HELPER || value->v.handle.tag == SCR_DYNH_MAP || value->v.handle.tag == SCR_DYNH_SET || scr_dyn_handle_ops_of(value)->iter_pack || scr_dyn_handle_ops_of(value)->iter_step)); } static bool scr_iterator_object(const ScrDyn *value) { @@ -4657,8 +4657,16 @@ bool scr_dyn_iterator_step_done(const ScrDyn *value) { return ((ScrNativeIterator *)value->v.handle.ptr)->step_done; } +static int scr_ih_method_index(const char *name, size_t len); +static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc); +static ScrDyn *scr_ih_method_function(int index); + static ScrDyn *scr_native_iterator_get(void *ptr, const char *key, size_t len) { - if (len != 4 || memcmp(key, "next", 4)) return NULL; + if (len != 4 || memcmp(key, "next", 4)) { + /* The Iterator.prototype helper methods. */ + int index = scr_ih_method_index(key, len); + return index >= 0 ? scr_ih_method_function(index) : NULL; + } unsigned kind = ((ScrNativeIterator *)ptr)->kind; if (kind == 4) return scr_iterator_function(10, &scr_native_handle_iterator_next_call, "next"); return kind == 3 ? scr_iterator_function(8, &scr_native_set_iterator_next_call, "next") @@ -4669,8 +4677,9 @@ static ScrDyn *scr_native_iterator_get(void *ptr, const char *key, size_t len) { static ScrDyn *scr_native_iterator_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { - (void)self; if (!strcmp(method, "next")) return scr_native_iterator_next(ptr); + int index = scr_ih_method_index(method, strlen(method)); + if (index >= 0) return scr_iterator_helper_method(self, index, args, argc); ScrDyn *missing = scr_dyn_undefined(); return scr_dyn_call(missing, args, argc, what); } @@ -4700,6 +4709,682 @@ static ScrDyn *scr_native_collection_iterator(ScrDyn *source, unsigned selection return result; } +/* ── iterator helpers on checked iterators (ES2025) ───────────────────────── + * The Iterator.prototype methods over a checked iterator object: a native + * array, string, Map or Set iterator, or a helper object made here. Typed + * code lowers its helpers statically; these answer untyped JavaScript + * receivers. The stages (map, filter, take, drop, flatMap) make helper + * objects that pull lazily; the consuming methods (toArray, forEach, reduce, + * some, every, find) drive the iterator to completion or to their answer. + * Closing follows IteratorClose: a helper closes the iterator it was made + * from when a callback throws, when its budget is spent, and on return(); + * an error from a close that runs because of another error is dropped. */ + +static ScrDyn *scr_iterator_call(const ScrDyn *method, const ScrDyn *receiver, const char *name); +static ScrDyn *scr_iterator_method(const ScrDyn *source); + +enum { SCR_IH_MAP, SCR_IH_FILTER, SCR_IH_TAKE, SCR_IH_DROP, SCR_IH_FLATMAP }; +enum { SCR_IH_START, SCR_IH_YIELDED, SCR_IH_RUNNING, SCR_IH_DONE }; + +typedef struct { + size_t rc; + unsigned kind; + unsigned state; + ScrDyn *iterated; /* the iterator the helper was made from */ + ScrDyn *next; /* its next method, read when the helper was made */ + ScrDyn *fn; /* the map, filter or flatMap callback */ + double remaining; /* the take or drop budget */ + double counter; + ScrDyn *inner; /* flatMap's current inner iterator */ + ScrDyn *inner_next; +} ScrIterHelper; + +static const char *const scr_ih_method_names[] = { + "map", "filter", "take", "drop", "flatMap", "reduce", "toArray", "forEach", "some", "every", "find", +}; +#define SCR_IH_METHOD_COUNT (sizeof scr_ih_method_names / sizeof scr_ih_method_names[0]) + +/* The index of an Iterator.prototype helper method name, or -1. */ +static int scr_ih_method_index(const char *name, size_t len) { + for (size_t i = 0; i < SCR_IH_METHOD_COUNT; i++) { + if (strlen(scr_ih_method_names[i]) == len && !memcmp(scr_ih_method_names[i], name, len)) return (int)i; + } + return -1; +} + +/* V8's description of a value in " is not a function": the type, + * with the value for primitives that print one. */ +static void scr_ih_describe_type(ScrJsonBuf *b, const ScrDyn *v) { + switch (v->kind) { + case SCR_DYN_UNDEF: scr_jb_puts(b, "undefined"); return; + case SCR_DYN_NULL: scr_jb_puts(b, "object null"); return; + case SCR_DYN_BOOL: scr_jb_puts(b, v->v.b ? "boolean true" : "boolean false"); return; + case SCR_DYN_NUM: { + scr_jb_puts(b, "number "); + ScrStr *n = scr_f64_to_scrstr(v->v.num); + scr_jb_write(b, n->data, n->len); + scr_str_release(n); + return; + } + case SCR_DYN_STR: + scr_jb_puts(b, "string \""); + scr_jb_write(b, v->v.str->data, v->v.str->len); + scr_jb_putc(b, '"'); + return; + case SCR_DYN_SYMBOL: scr_jb_puts(b, "symbol"); return; + case SCR_DYN_BIGINT: scr_jb_puts(b, "bigint"); return; + default: scr_jb_puts(b, "object"); return; + } +} + +static bool scr_ih_require_callable(const ScrDyn *fn) { + if (scr_dyn_is_callable(fn)) return true; + ScrJsonBuf b; + scr_jb_init(&b); + scr_ih_describe_type(&b, fn); + scr_jb_puts(&b, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return false; +} + +/* V8's text for the value in " must be positive": numbers, strings + * raw, undefined by name, + * arrays and objects by their default string or constructor name. */ +static void scr_ih_describe_value(ScrJsonBuf *b, const ScrDyn *v) { + if (v->kind == SCR_DYN_ARR) { + scr_jb_puts(b, "[object Array]"); + return; + } + if (v->kind == SCR_DYN_OBJ) { + scr_jb_puts(b, "#"); + return; + } + if (v->kind == SCR_DYN_HANDLE) { + scr_jb_puts(b, "#<"); + scr_jb_puts(b, scr_dyn_handle_ops_of(v)->cls); + scr_jb_putc(b, '>'); + return; + } + if (v->kind == SCR_DYN_STR) { + scr_jb_write(b, v->v.str->data, v->v.str->len); + return; + } + if (v->kind == SCR_DYN_NUM) { + ScrStr *n = scr_f64_to_scrstr(v->v.num); + scr_jb_write(b, n->data, n->len); + scr_str_release(n); + return; + } + scr_jb_puts(b, v->kind == SCR_DYN_UNDEF ? "undefined" : "NaN"); +} + +/* ToIntegerOrInfinity of a take/drop budget with Node's RangeError for NaN + * and negative budgets. Answers false with the exception pending. */ +static bool scr_ih_budget(const ScrDyn *limit, double *out) { + double n = scr_dyn_number_coerce(limit); + if (scr_exc_pending()) return false; + if (n != n) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_ih_describe_value(&b, limit); + scr_jb_puts(&b, " must be positive"); + scr_throw_error(SCR_ERR_RANGE, scr_jb_finish(&b)); + return false; + } + double t = n < 9007199254740992.0 && n > -9007199254740992.0 ? n - fmod(n, 1.0) : n; + if (t < 0) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_ih_describe_value(&b, limit); + scr_jb_puts(&b, " must be positive"); + scr_throw_error(SCR_ERR_RANGE, scr_jb_finish(&b)); + return false; + } + *out = t == 0 ? 0 : t; + return true; +} + +/* IteratorClose(iterator, completion). `abrupt` is a throw completion: the + * pending error stays and anything the return() call raises is dropped. + * Otherwise errors from return() propagate, and a non-object result is + * Node's TypeError. Answers false when an exception is pending after. */ +static bool scr_ih_close(ScrDyn *iterator, bool abrupt) { + ScrCaught *first = abrupt ? scr_exc_take() : NULL; + ScrDyn *method = scr_iterator_read(iterator, "return", 6); + bool ok = method != NULL; + if (ok && method->kind != SCR_DYN_UNDEF && method->kind != SCR_DYN_NULL) { + ScrDyn *result = scr_iterator_call(method, iterator, "return"); + if (!result) ok = false; + else { + if (!abrupt && !scr_iterator_object(result)) { + static const char message[] = "Iterator result is not an object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + ok = false; + } + scr_dyn_release(result); + } + } + scr_dyn_release(method); + if (first) { + if (scr_exc_pending()) { + ScrCaught *dropped = scr_exc_take(); + if (dropped) scr_caught_release(dropped); + } + scr_rethrow(first); + scr_caught_release(first); + return false; + } + return ok && !scr_exc_pending(); +} + +/* IteratorStepValue over (iterator, next): +1 value with *done false, or + * *done true (the answer is then undefined); NULL with the exception + * pending when next() throws or answers a non-object. */ +static ScrDyn *scr_ih_step(ScrDyn *iterator, ScrDyn *next, bool *done) { + *done = false; + if (scr_dyn_iterator_can_step(iterator, next)) { + ScrDyn *value = scr_dyn_iterator_step(iterator); + if (!value) return NULL; + *done = scr_dyn_iterator_step_done(iterator); + return value; + } + ScrDyn *raw = scr_iterator_call(next, iterator, "next"); + if (!raw) return NULL; + ScrDyn *result = scr_dyn_iterator_result(raw); + scr_dyn_release(raw); + if (!result) return NULL; + ScrDyn *finished = scr_iterator_read(result, "done", 4); + if (!finished) { + scr_dyn_release(result); + return NULL; + } + *done = scr_dyn_truthy(finished); + scr_dyn_release(finished); + ScrDyn *value = *done ? scr_dyn_retain(scr_dyn_undefined()) : scr_iterator_read(result, "value", 5); + scr_dyn_release(result); + return value; +} + +/* GetIteratorDirect: the receiver must be an object; its next method is + * read once. +1 next, or NULL with the TypeError pending. */ +static ScrDyn *scr_ih_direct(const ScrDyn *self, const char *method) { + if (!scr_iterator_object(self)) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Iterator.prototype."); + scr_jb_puts(&b, method); + scr_jb_puts(&b, " called on non-object"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return NULL; + } + return scr_iterator_read(self, "next", 4); +} + +static ScrDyn *scr_ih_result(ScrDyn *value, bool done) { + ScrDyn *result = scr_dyn_new_obj(); + scr_dyn_obj_set(result, "value", 5, value); + scr_dyn_obj_set(result, "done", 4, scr_dyn_new_bool(done)); + return result; +} + +static void *scr_ih_retain(void *ptr) { + ((ScrIterHelper *)ptr)->rc++; + return ptr; +} + +static void scr_ih_release(void *ptr) { + ScrIterHelper *h = ptr; + if (--h->rc) return; + scr_dyn_release(h->iterated); + scr_dyn_release(h->next); + if (h->fn) scr_dyn_release(h->fn); + if (h->inner) scr_dyn_release(h->inner); + if (h->inner_next) scr_dyn_release(h->inner_next); + free(h); +} + +static void scr_ih_clear_inner(ScrIterHelper *h) { + if (h->inner) scr_dyn_release(h->inner); + if (h->inner_next) scr_dyn_release(h->inner_next); + h->inner = NULL; + h->inner_next = NULL; +} + +/* GetIteratorFlattenable(value, reject-primitives) for flatMap's callback + * result: +1 iterator with *next_out +1, or NULL with the TypeError pending. */ +static ScrDyn *scr_ih_flattenable(ScrDyn *value, ScrDyn **next_out) { + if (!scr_iterator_object(value)) { + static const char message[] = "Iterator.prototype.flatMap called on non-object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + ScrDyn *method = scr_iterator_method(value); + if (!method) return NULL; + ScrDyn *iterator; + if (method->kind == SCR_DYN_UNDEF || method->kind == SCR_DYN_NULL) { + iterator = scr_dyn_retain(value); + } else { + iterator = scr_iterator_call(method, value, "value[Symbol.iterator]"); + if (iterator && !scr_iterator_object(iterator)) { + scr_dyn_release(iterator); + static const char message[] = "Iterator result is not an object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + iterator = NULL; + } + } + scr_dyn_release(method); + if (!iterator) return NULL; + ScrDyn *next = scr_iterator_read(iterator, "next", 4); + if (!next) { + scr_dyn_release(iterator); + return NULL; + } + *next_out = next; + return iterator; +} + +/* Calls a stage callback with (value, counter); +1 result or NULL. */ +static ScrDyn *scr_ih_callback(ScrDyn *fn, ScrDyn *value, double counter) { + ScrDyn *index = scr_dyn_new_num(counter); + ScrDyn *args[2] = { value, index }; + ScrDyn *undefined = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_this_push_dyn(undefined); + ScrDyn *result = scr_dyn_call(fn, args, 2, "callback"); + scr_dyn_this_pop(); + scr_dyn_release(undefined); + scr_dyn_release(index); + return result; +} + +/* The helper's next value: +1 value with *done false, *done true, or NULL + * with the exception pending (the helper is then done). */ +static ScrDyn *scr_ih_advance(ScrIterHelper *h, bool *done) { + *done = false; + for (;;) { + if (h->kind == SCR_IH_FLATMAP && h->inner) { + bool inner_done; + ScrDyn *value = scr_ih_step(h->inner, h->inner_next, &inner_done); + if (!value) { + scr_ih_close(h->iterated, true); + return NULL; + } + if (!inner_done) return value; + scr_dyn_release(value); + scr_ih_clear_inner(h); + continue; + } + if (h->kind == SCR_IH_TAKE) { + if (h->remaining == 0) { + *done = true; + if (!scr_ih_close(h->iterated, false)) return NULL; + return scr_dyn_retain(scr_dyn_undefined()); + } + if (h->remaining != INFINITY) h->remaining--; + } + if (h->kind == SCR_IH_DROP) { + while (h->remaining > 0) { + if (h->remaining != INFINITY) h->remaining--; + bool skipped_done; + ScrDyn *skipped = scr_ih_step(h->iterated, h->next, &skipped_done); + if (!skipped) return NULL; + scr_dyn_release(skipped); + if (skipped_done) { + *done = true; + return scr_dyn_retain(scr_dyn_undefined()); + } + } + } + bool source_done; + ScrDyn *value = scr_ih_step(h->iterated, h->next, &source_done); + if (!value) return NULL; + if (source_done) { + *done = true; + return value; + } + if (h->kind == SCR_IH_TAKE || h->kind == SCR_IH_DROP) return value; + ScrDyn *mapped = scr_ih_callback(h->fn, value, h->counter); + if (!mapped) { + scr_dyn_release(value); + scr_ih_close(h->iterated, true); + return NULL; + } + h->counter++; + if (h->kind == SCR_IH_MAP) { + scr_dyn_release(value); + return mapped; + } + if (h->kind == SCR_IH_FILTER) { + bool keep = scr_dyn_truthy(mapped); + scr_dyn_release(mapped); + if (keep) return value; + scr_dyn_release(value); + continue; + } + /* flatMap */ + scr_dyn_release(value); + ScrDyn *inner_next = NULL; + ScrDyn *inner = scr_ih_flattenable(mapped, &inner_next); + scr_dyn_release(mapped); + if (!inner) { + scr_ih_close(h->iterated, true); + return NULL; + } + h->inner = inner; + h->inner_next = inner_next; + } +} + +static void scr_ih_running_error(void) { + static const char message[] = "Generator is already running"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); +} + +static ScrDyn *scr_ih_next(ScrIterHelper *h) { + if (h->state == SCR_IH_RUNNING) { + scr_ih_running_error(); + return NULL; + } + if (h->state == SCR_IH_DONE) return scr_ih_result(scr_dyn_retain(scr_dyn_undefined()), true); + h->state = SCR_IH_RUNNING; + scr_ih_retain(h); + bool done; + ScrDyn *value = scr_ih_advance(h, &done); + ScrDyn *result = NULL; + if (!value || done) { + h->state = SCR_IH_DONE; + scr_ih_clear_inner(h); + if (value) { + scr_dyn_release(value); + result = scr_ih_result(scr_dyn_retain(scr_dyn_undefined()), true); + } + } else { + h->state = SCR_IH_YIELDED; + result = scr_ih_result(value, false); + } + scr_ih_release(h); + return result; +} + +/* %IteratorHelperPrototype%.return: an unstarted or suspended helper + * completes and closes the iterator it was made from (flatMap closes its + * inner iterator first). */ +static ScrDyn *scr_ih_return(ScrIterHelper *h) { + if (h->state == SCR_IH_RUNNING) { + scr_ih_running_error(); + return NULL; + } + if (h->state != SCR_IH_DONE) { + h->state = SCR_IH_DONE; + scr_ih_retain(h); + bool ok = true; + if (h->inner) { + ScrDyn *inner = scr_dyn_retain(h->inner); + scr_ih_clear_inner(h); + if (!scr_ih_close(inner, false)) { + scr_ih_close(h->iterated, true); + ok = false; + } + scr_dyn_release(inner); + } + if (ok) ok = scr_ih_close(h->iterated, false); + scr_ih_release(h); + if (!ok) return NULL; + } + return scr_ih_result(scr_dyn_retain(scr_dyn_undefined()), true); +} + +static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc); + +static ScrDyn *scr_ih_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (!strcmp(method, "next")) return scr_ih_next(ptr); + if (!strcmp(method, "return")) return scr_ih_return(ptr); + int index = scr_ih_method_index(method, strlen(method)); + if (index >= 0) return scr_iterator_helper_method(self, index, args, argc); + return scr_dyn_call(scr_dyn_undefined(), args, argc, what); +} + +static ScrDyn *scr_ih_method_function(int index); + +static ScrDyn *scr_ih_next_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)clo; (void)args; (void)argc; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == SCR_DYNH_ITERATOR_HELPER) { + result = scr_ih_next(self->v.handle.ptr); + } else { + static const char message[] = "Method Iterator Helper.prototype.next called on incompatible receiver"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(self); + return result; +} + +static ScrDyn *scr_ih_return_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)clo; (void)args; (void)argc; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == SCR_DYNH_ITERATOR_HELPER) { + result = scr_ih_return(self->v.handle.ptr); + } else { + static const char message[] = "Method Iterator Helper.prototype.return called on incompatible receiver"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(self); + return result; +} + +static SCR_TL ScrDyn *scr_ih_functions[SCR_IH_METHOD_COUNT + 2]; +static SCR_TL bool scr_ih_functions_registered; + +static void scr_ih_functions_cleanup(void) { + for (size_t i = 0; i < SCR_IH_METHOD_COUNT + 2; i++) { + scr_dyn_release(scr_ih_functions[i]); + scr_ih_functions[i] = NULL; + } +} + +static ScrDyn *scr_ih_function(size_t slot, ScrDynThunk thunk, const char *name) { + if (!scr_ih_functions_registered) { + scr_ih_functions_registered = true; + scr_atexit(scr_ih_functions_cleanup); + } + if (!scr_ih_functions[slot]) + scr_ih_functions[slot] = scr_dyn_new_func(scr_closure_new(NULL, 0), thunk, 0, "native:iterator", name); + return scr_dyn_retain(scr_ih_functions[slot]); +} + +static ScrDyn *scr_ih_get(void *ptr, const char *key, size_t len) { + (void)ptr; + if (len == 4 && !memcmp(key, "next", 4)) return scr_ih_function(SCR_IH_METHOD_COUNT, &scr_ih_next_call, "next"); + if (len == 6 && !memcmp(key, "return", 6)) + return scr_ih_function(SCR_IH_METHOD_COUNT + 1, &scr_ih_return_call, "return"); + int index = scr_ih_method_index(key, len); + return index >= 0 ? scr_ih_method_function(index) : NULL; +} + +static ScrDyn *scr_ih_inspect_view(void *ptr) { + (void)ptr; + return scr_dyn_new_obj(); +} + +static ScrDyn *scr_ih_new(unsigned kind, ScrDyn *iterated, ScrDyn *next, ScrDyn *fn, double remaining) { + static const ScrDynHandleOps ops = { + .cls = "Object", + .retain = &scr_ih_retain, + .release = &scr_ih_release, + .invoke = &scr_ih_invoke, + .get = &scr_ih_get, + .inspect_view = &scr_ih_inspect_view, + .to_string_tag = "Iterator Helper", + .inspect_view_empty = true, + }; + scr_dyn_handle_install(SCR_DYNH_ITERATOR_HELPER, &ops); + ScrIterHelper *h = malloc(sizeof *h); + if (!h) scr_json_oom(); + *h = (ScrIterHelper){ .rc = 1, .kind = kind, .state = SCR_IH_START, .iterated = scr_dyn_retain(iterated), + .next = next, .fn = fn ? scr_dyn_retain(fn) : NULL, .remaining = remaining }; + ScrDyn *result = scr_dyn_new_handle(h, SCR_DYNH_ITERATOR_HELPER); + scr_ih_release(h); + return result; +} + +/* The consuming methods: drive (self, next) with `fn`. */ +static ScrDyn *scr_ih_consume(ScrDyn *self, ScrDyn *next, int index, ScrDyn *fn, ScrDyn *const *args, + size_t argc) { + const char *name = scr_ih_method_names[index]; + bool reduce = !strcmp(name, "reduce"); + ScrDyn *out = !strcmp(name, "toArray") ? scr_dyn_new_arr() : NULL; + ScrDyn *acc = NULL; + double counter = 0; + if (reduce && argc >= 2) acc = scr_dyn_retain(args[1]); + else if (reduce) { + bool done; + ScrDyn *first = scr_ih_step(self, next, &done); + if (!first) return NULL; + if (done) { + scr_dyn_release(first); + static const char message[] = "Reduce of a done iterator with no initial value"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + acc = first; + counter = 1; + } + for (;;) { + bool done; + ScrDyn *value = scr_ih_step(self, next, &done); + if (!value) { + if (out) scr_dyn_release(out); + if (acc) scr_dyn_release(acc); + return NULL; + } + if (done) { + scr_dyn_release(value); + break; + } + if (out) { + scr_dyn_arr_push(out, value); + continue; + } + ScrDyn *answer; + if (reduce) { + ScrDyn *index_value = scr_dyn_new_num(counter); + ScrDyn *call_args[3] = { acc, value, index_value }; + ScrDyn *undefined = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_this_push_dyn(undefined); + answer = scr_dyn_call(fn, call_args, 3, "callback"); + scr_dyn_this_pop(); + scr_dyn_release(undefined); + scr_dyn_release(index_value); + } else { + answer = scr_ih_callback(fn, value, counter); + } + counter++; + if (!answer) { + scr_dyn_release(value); + if (acc) scr_dyn_release(acc); + scr_ih_close(self, true); + return NULL; + } + if (reduce) { + scr_dyn_release(acc); + acc = answer; + scr_dyn_release(value); + continue; + } + bool truthy = scr_dyn_truthy(answer); + scr_dyn_release(answer); + if (!strcmp(name, "forEach")) { + scr_dyn_release(value); + continue; + } + bool stop = !strcmp(name, "every") ? !truthy : truthy; + if (!stop) { + scr_dyn_release(value); + continue; + } + if (!scr_ih_close(self, false)) { + scr_dyn_release(value); + return NULL; + } + if (!strcmp(name, "find")) return value; + scr_dyn_release(value); + return scr_dyn_new_bool(!strcmp(name, "some")); + } + if (out) return out; + if (reduce) return acc; + if (!strcmp(name, "some") || !strcmp(name, "every")) return scr_dyn_new_bool(!strcmp(name, "every")); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* An Iterator.prototype helper method called on `self` (borrowed). */ +static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc) { + const char *name = scr_ih_method_names[index]; + ScrDyn *next = scr_ih_direct(self, name); + if (!next) return NULL; + ScrDyn *arg = argc > 0 ? args[0] : scr_dyn_undefined(); + if (!strcmp(name, "take") || !strcmp(name, "drop")) { + double budget; + if (!scr_ih_budget(arg, &budget)) { + scr_dyn_release(next); + scr_ih_close(self, true); + return NULL; + } + return scr_ih_new(!strcmp(name, "take") ? SCR_IH_TAKE : SCR_IH_DROP, self, next, NULL, budget); + } + if (!strcmp(name, "toArray")) { + ScrDyn *result = scr_ih_consume(self, next, index, NULL, args, argc); + scr_dyn_release(next); + return result; + } + if (!scr_ih_require_callable(arg)) { + scr_dyn_release(next); + scr_ih_close(self, true); + return NULL; + } + if (index < 5) { + unsigned kind = !strcmp(name, "map") ? SCR_IH_MAP : !strcmp(name, "filter") ? SCR_IH_FILTER : SCR_IH_FLATMAP; + return scr_ih_new(kind, self, next, arg, 0); + } + ScrDyn *result = scr_ih_consume(self, next, index, arg, args, argc); + scr_dyn_release(next); + return result; +} + +static ScrDyn *scr_ih_method_call(int index, ScrDyn *const *args, size_t argc) { + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = scr_iterator_helper_method(self, index, args, argc); + scr_dyn_release(self); + return result; +} + +#define SCR_IH_THUNK(i) \ + static ScrDyn *scr_ih_method_call_##i(ScrClosure *clo, ScrDyn *const *args, size_t argc) { \ + (void)clo; \ + return scr_ih_method_call(i, args, argc); \ + } +SCR_IH_THUNK(0) +SCR_IH_THUNK(1) +SCR_IH_THUNK(2) +SCR_IH_THUNK(3) +SCR_IH_THUNK(4) +SCR_IH_THUNK(5) +SCR_IH_THUNK(6) +SCR_IH_THUNK(7) +SCR_IH_THUNK(8) +SCR_IH_THUNK(9) +SCR_IH_THUNK(10) + +static ScrDyn *scr_ih_method_function(int index) { + static const ScrDynThunk thunks[SCR_IH_METHOD_COUNT] = { + &scr_ih_method_call_0, &scr_ih_method_call_1, &scr_ih_method_call_2, &scr_ih_method_call_3, + &scr_ih_method_call_4, &scr_ih_method_call_5, &scr_ih_method_call_6, &scr_ih_method_call_7, + &scr_ih_method_call_8, &scr_ih_method_call_9, &scr_ih_method_call_10, + }; + return scr_ih_function((size_t)index, thunks[index], scr_ih_method_names[index]); +} + ScrDyn *scr_dyn_native_handle_iterator(ScrDyn *source, unsigned selection) { return scr_native_collection_iterator(source, selection); } diff --git a/tests/corpus/6070-iterator-helpers-untyped.js b/tests/corpus/6070-iterator-helpers-untyped.js new file mode 100644 index 000000000..4f528f0d3 --- /dev/null +++ b/tests/corpus/6070-iterator-helpers-untyped.js @@ -0,0 +1,89 @@ +// Iterator helpers on untyped iterators in JavaScript: the receivers come +// from parameters without inferred types, so the helpers run on the +// runtime's iterator objects. +const log = []; +function flush(what) { + console.log(what, "|", log.join(" ")); + log.length = 0; +} +function values(xs) { + return xs.values(); +} +function keysOf(m) { + return m.keys(); +} +function charsOf(s) { + return s[Symbol.iterator](); +} + +// Stages and consuming methods. +const evens = values([1, 2, 3, 4, 6]).filter((x) => x % 2 === 0); +console.log(evens.next(), evens.take(1).toArray(), [...evens]); +console.log(values([1, 2, 3]).map((x, i) => x * 10 + i).toArray(), values([]).map((x) => x).toArray()); +console.log(values([1, 2, 3]).reduce((a, b) => a + b), values([1, 2]).reduce((a, b, i) => a + b * i, 100)); +console.log(values([5, 6, 7]).drop(1).toArray(), values([5, 6, 7]).take(Infinity).toArray()); +console.log(values(new Set([1, 3, 5])).find((x) => x > 2), values(new Set()).find(() => true)); +console.log(values([1, 2, 3]).some((x) => x === 2), values([1, 2, 3]).every((x) => x < 3)); +console.log(keysOf(new Map([["a", 1], ["b", 2]])).map((k) => k.toUpperCase()).toArray()); +console.log(charsOf("a-b-c").filter((c) => c !== "-").toArray().join("")); +const seen = []; +values(["p", "q"]).forEach((v, i) => seen.push(v + i)); +console.log(seen.join(",")); + +// Helper objects. +const doubled = values([1, 2, 3]).map((x) => x * 2); +console.log(typeof doubled.next, typeof doubled.return, typeof doubled.map, typeof doubled.throw); +console.log(doubled.next(), doubled.return(), doubled.next()); +for (const x of values([1, 2, 3]).drop(1)) log.push(`of${x}`); +flush("for...of"); +console.log([...values([4, 5]).flatMap((x) => [x, x])]); +console.log(Object.prototype.toString.call(doubled), String(doubled)); +console.log(doubled); +let self = null; +self = values([1, 2]).map((x) => { + if (x === 2) { + try { + self.next(); + } catch (e) { + log.push(`${e.name}: ${e.message}`); + } + } + return x; +}); +flush(self.toArray()); + +// flatMap closes an inner iterator it leaves early. +function inner(label) { + let n = 0; + return { + next() { + n++; + return { value: `${label}${n}`, done: n > 3 }; + }, + return() { + log.push(`${label}:closed`); + return { done: true }; + }, + }; +} +flush(values([1, 2]).flatMap((x) => inner(`i${x}`)).take(2).toArray()); +const stepping = values([1]).flatMap(() => inner("j")); +stepping.next(); +flush([stepping.return(), stepping.next()]); + +// Node's errors for invalid arguments. +function attempt(f) { + try { + console.log(f()); + } catch (e) { + console.log(`${e.name}: ${e.message}`); + } +} +attempt(() => values([1]).take("x")); +attempt(() => values([1]).drop(-1)); +attempt(() => values([1]).map(5)); +attempt(() => values([1]).filter(null)); +attempt(() => values([1]).reduce(undefined)); +attempt(() => values([1]).forEach("f")); +attempt(() => values([]).reduce((a, b) => a)); +attempt(() => values([1, 2]).flatMap((x) => x).toArray()); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 48b392237..8131c1cd2 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2222,6 +2222,7 @@ "6043-string-index-narrowed-union.ts": 2983, "605-closures-forward-capture-tdz.ts": 3277, "6051-generic-class-factory-results.ts": 4363, + "6070-iterator-helpers-untyped.js": 4050, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, "6103-typeof-builtin-bindings.cjs": 2202, @@ -5280,6 +5281,7 @@ "6043-string-index-narrowed-union.ts": 3627, "605-closures-forward-capture-tdz.ts": 4638, "6051-generic-class-factory-results.ts": 46581, + "6070-iterator-helpers-untyped.js": 6900, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, "6103-typeof-builtin-bindings.cjs": 1796,