From 26678e3c72385b44fe784ffea31c6a3f7fe2318c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 13:20:59 -0500 Subject: [PATCH 1/9] Keep class methods native when element writes store data under their names --- benchmarks/runtime/README.md | 1 + .../workloads/keyed-option-writes.json | 6 ++ .../runtime/workloads/keyed-option-writes.ts | 76 ++++++++++++++++++ .../compiler/src/contracts/int32-elements.ts | 3 +- .../frontend/lowering/builtins/webassembly.ts | 5 +- .../lowering/class-method-mutations.ts | 53 +++++++++++-- .../lowering/containers/array-pipelines.ts | 4 +- .../lowering/containers/iterator-helpers.ts | 18 +++-- .../frontend/lowering/generic-functions.ts | 6 +- .../lowering/instantiation-path.test.ts | 12 ++- .../frontend/lowering/instantiation-path.ts | 6 +- .../compiler/src/frontend/lowering/lowerer.ts | 58 +++++++++----- .../lowering/site-dependencies.test.ts | 14 ++-- .../frontend/lowering/site-dependencies.ts | 15 +++- .../frontend/lowering/string-index-bounds.ts | 23 +++++- packages/compiler/src/frontend/npm-static.ts | 59 +++++++------- packages/compiler/src/frontend/services.ts | 9 ++- .../src/frontend/ts7/semantic-model.ts | 4 +- .../src/frontend/ts7/source-parser.ts | 4 +- .../compiler/test/keyed-method-writes.test.ts | 79 +++++++++++++++++++ 20 files changed, 361 insertions(+), 94 deletions(-) create mode 100644 benchmarks/runtime/workloads/keyed-option-writes.json create mode 100644 benchmarks/runtime/workloads/keyed-option-writes.ts create mode 100644 packages/compiler/test/keyed-method-writes.test.ts diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 4f152e4bb..f7c21571a 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | +| `keyed-option-writes` | cpu | class methods whose names are also keys of option records written through element accesses: `enabled[name] = true` over a union of literal names and `totals["count"] = n` | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | | `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes | diff --git a/benchmarks/runtime/workloads/keyed-option-writes.json b/benchmarks/runtime/workloads/keyed-option-writes.json new file mode 100644 index 000000000..802911e7d --- /dev/null +++ b/benchmarks/runtime/workloads/keyed-option-writes.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/keyed-option-writes.ts", + "args": [ + "4" + ] +} diff --git a/benchmarks/runtime/workloads/keyed-option-writes.ts b/benchmarks/runtime/workloads/keyed-option-writes.ts new file mode 100644 index 000000000..95cdbb86b --- /dev/null +++ b/benchmarks/runtime/workloads/keyed-option-writes.ts @@ -0,0 +1,76 @@ +// Class methods whose names are also keys of option records written through +// element accesses: `enabled[name] = true` over a union of literal names and +// `totals["count"] = n`. The writes store booleans and numbers into plain +// records, so the methods keep their native calls. +type Step = "scale" | "offset" | "clamp"; + +const enabled: Record = { scale: false, offset: false, clamp: false }; +function enable(name: Step): void { + enabled[name] = true; +} + +const totals: Record = {}; + +class Transform { + private readonly factor: number; + private readonly shift: number; + constructor(factor: number, shift: number) { + this.factor = factor; + this.shift = shift; + } + scale(x: number): number { + return enabled.scale ? x * this.factor : x; + } + offset(x: number): number { + return enabled.offset ? x + this.shift : x; + } + clamp(x: number): number { + if (!enabled.clamp) return x; + return x < 0 ? 0 : x > 1000 ? 1000 : x; + } +} + +class Counter { + private seen = 0; + private sum = 0; + add(value: number): void { + this.seen++; + this.sum += value; + } + count(): number { + return this.seen; + } + mean(): number { + return this.seen === 0 ? 0 : this.sum / this.seen; + } +} + +const scale = Number(process.argv[2] ?? "1"); +const steps: Step[] = ["scale", "offset", "clamp"]; +for (const step of steps) enable(step); + +const transforms: Transform[] = []; +for (let i = 0; i < 16; i++) transforms.push(new Transform(1 + (i % 5) * 0.25, (i % 7) - 3)); +const counters: Counter[] = []; +for (let i = 0; i < 8; i++) counters.push(new Counter()); + +let seed = 12345; +let checksum = 0; +const rounds = Math.floor(2_000_000 * scale); +for (let i = 0; i < rounds; i++) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + const t = transforms[seed & 15]!; + const value = t.clamp(t.offset(t.scale((seed >>> 8) % 1200))); + const counter = counters[(seed >>> 4) & 7]!; + counter.add(value); + checksum += value; + if ((i & 65535) === 0) totals["count"] = counter.count(); +} + +let mean = 0; +let seen = 0; +for (const counter of counters) { + mean += counter.mean(); + seen += counter.count(); +} +console.log("seen", seen, "count", totals["count"], "mean", (mean / counters.length).toFixed(4), "checksum", checksum); diff --git a/packages/compiler/src/contracts/int32-elements.ts b/packages/compiler/src/contracts/int32-elements.ts index ca61a8e49..67446f2d1 100644 --- a/packages/compiler/src/contracts/int32-elements.ts +++ b/packages/compiler/src/contracts/int32-elements.ts @@ -709,7 +709,8 @@ export class Int32ElementContracts { const findChainsIn = (st: IrStmt): boolean => everyStmtChild(st, findChains, findChainsIn); for (const st of fn.body) findChainsIn(st); // Parameters whose callers are not all visible hold opaque arrays. - if (this.valueEscaped.has(fn.name) || this.runtimeVirtual(fn.name)) + const runtimeVirtual = this.runtimeVirtual; + if (this.valueEscaped.has(fn.name) || runtimeVirtual(fn.name)) for (const p of fn.params) if (this.holds(p.type)) { const why = this.valueEscaped.has(fn.name) diff --git a/packages/compiler/src/frontend/lowering/builtins/webassembly.ts b/packages/compiler/src/frontend/lowering/builtins/webassembly.ts index 8e5c3a048..f33ebe9fe 100644 --- a/packages/compiler/src/frontend/lowering/builtins/webassembly.ts +++ b/packages/compiler/src/frontend/lowering/builtins/webassembly.ts @@ -403,7 +403,10 @@ export function lowerWebAssemblyValueOf( recv: IrExpr, loc: IrExpr["loc"], ): IrExpr | null { - const ctx = { checker: lowerer.checker, isStdlibFile: lowerer.isStdlibFile }; + const ctx = { + checker: lowerer.checker, + isStdlibFile: (sf: ts.SourceFile) => lowerer.isStdlibFile(sf), + }; if (!isWebAssemblyType(lowerer.typeOf(receiver), ctx)) return null; lowerer.usesWebAssembly = true; return { kind: "libCall", fn: "wasm.valueOf", args: [recv], type: DYN, loc }; diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index fd23b6db8..d75f1d499 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -36,9 +36,20 @@ export function collectClassMethodMutations( const computedWrites: { key: ts.Expression | undefined; receiver: ts.Expression | undefined; + /** The written element access, when the write has one. */ + target?: ts.ElementAccessExpression; site: ts.Node; }[] = []; const propertyWrites: { target: ts.PropertyAccessExpression; site: ts.Node }[] = []; + /** Writes and deletes of a method name through an element access with a + * known key (`o["m"] = f`, `o[k] = f` with `k: "m" | "n"`), judged like + * the named writes in propertyWrites. */ + const keyedWrites: { + name: string; + receiver: ts.Expression; + target: ts.ElementAccessExpression; + site: ts.Node; + }[] = []; const reflection: ts.PropertyAccessExpression[] = []; const globals: ts.Identifier[] = []; const jsAssignments: (ts.PropertyAccessExpression | ts.ElementAccessExpression)[] = []; @@ -242,13 +253,32 @@ export function collectClassMethodMutations( sameBinding(receiver, keys.arguments.nodes[0]!) ); }; - const markKey = (key: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { + /** A write of the known key `name`: through an element access, a write + * to a method name is judged like a named write (keyedWrites). */ + const markKeyed = ( + name: string, + site: ts.Node, + receiver?: ts.Expression, + target?: ts.ElementAccessExpression, + ): void => { + if (receiver && target && methods.has(name) && name !== "prototype" && name !== "__proto__") { + receiverFields.add(name); + keyedWrites.push({ name, receiver, target, site }); + } else markName(name, site); + }; + const markKey = ( + key: ts.Expression, + site: ts.Node, + receiver?: ts.Expression, + target?: ts.ElementAccessExpression, + ): void => { const value = unwrap(key); - if (ts.isStringLiteralLike(value)) markName(value.text, site); - else if (ts.isNumericLiteral(value)) markName(String(Number(value.text)), site); + if (ts.isStringLiteralLike(value)) markKeyed(value.text, site, receiver, target); + else if (ts.isNumericLiteral(value)) + markKeyed(String(Number(value.text)), site, receiver, target); // The key keeps its assertions: `slots[at as number]` and `slots[at!]` // write a number key whatever the type of `at`. - else computedWrites.push({ key, receiver, site }); + else computedWrites.push(target ? { key, receiver, target, site } : { key, receiver, site }); }; const markFields = (value: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { value = unwrap(value); @@ -275,7 +305,7 @@ export function collectClassMethodMutations( propertyWrites.push({ target, site }); } else markName(name, site, arrow); } else if (ts.isElementAccessExpression(target)) - markKey(target.argumentExpression, site, target.expression); + markKey(target.argumentExpression, site, target.expression, target); else if (ts.isArrayLiteralExpression(target)) { for (const element of target.elements.nodes) { if (ts.isSpreadElement(element)) markTarget(element.expression, site); @@ -575,7 +605,7 @@ export function collectClassMethodMutations( if (!methodOwnerCanInhabit(target.expression, name)) continue; lowerer.prototypeMethodAccesses.set(name, site); } - for (const { key, receiver, site } of computedWrites) { + for (const { key, receiver, target, site } of computedWrites) { // A class method declared with a well-known symbol key is not a write; // any other write with one can replace that method. const symbolName = key ? wellKnownMethodName(unwrap(key)) : null; @@ -592,7 +622,7 @@ export function collectClassMethodMutations( const type = key ? lowerer.typeOf(key) : undefined; const names = type && (literalValues(type) ?? (key ? literalLoopKeys(unwrap(key)) : null)); if (names) { - for (const name of names) markName(String(name), site); + for (const name of names) markKeyed(String(name), site, receiver, target); continue; } if (key && receiver && preservesExistingMethod(key, receiver, site)) continue; @@ -614,6 +644,15 @@ export function collectClassMethodMutations( unknownKey = site; } } + lowerer.checker.prefetchClassCollection( + keyedWrites.flatMap((write) => [write.target, write.receiver]), + [], + ); + for (const { name, receiver, target, site } of keyedWrites) { + if (lowerer.prototypeMethodAccesses.has(name) || functionFree(lowerer.typeOf(target))) continue; + if (!methodOwnerCanInhabit(receiver, name)) continue; + lowerer.prototypeMethodAccesses.set(name, site); + } if (numericKey) { for (const name of [...methods, ...arrowFields]) { if (String(Number(name)) === name) markName(name, numericKey); diff --git a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts index 2f839a20b..ff7da5fd7 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts @@ -1019,7 +1019,9 @@ class FusedLoop { private callee(arg: IrExpr & { type: FuncType }): Callee { const known = this.callees.find((c) => c.arg === arg); if (known) return known; - const target = this.inlineTarget(arg); + // Called as a plain function: the loop is not the target's receiver. + const inlineTarget = this.inlineTarget; + const target = inlineTarget(arg); let callee: Callee; if (target && arg.kind === "closure") { const captures = new Map(); diff --git a/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts b/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts index ba5d0684d..f6580c70e 100644 --- a/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts +++ b/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts @@ -1182,10 +1182,10 @@ function bindSource( /** Where chain code allocates its locals: a lifted helper function's own * frame, or hidden locals of the function being lowered (a fused for-of). */ -interface ChainFrame { - local(base: string, type: IrType, mutable: boolean): string; +abstract class ChainFrame { + abstract local(base: string, type: IrType, mutable: boolean): string; /** `id = id + 1` over a number local. */ - bump(id: string): IrStmt; + abstract bump(id: string): IrStmt; } /** `id = id + 1` over a number local. */ @@ -1206,11 +1206,13 @@ function bumpLocal(id: string, loc: SrcLoc): IrStmt { } /** Hidden locals of the function being lowered. */ -class HostFrame implements ChainFrame { +class HostFrame extends ChainFrame { constructor( private readonly lowerer: Lowerer, private readonly loc: SrcLoc, - ) {} + ) { + super(); + } local(base: string, type: IrType, mutable: boolean): string { const local = this.lowerer.declareHiddenLocal(`%${base}`, type); local.mutable = mutable; @@ -1223,11 +1225,13 @@ class HostFrame implements ChainFrame { /** The locals and parameters of a lifted helper function under * construction. */ -export class LiftedFrame implements ChainFrame { +export class LiftedFrame extends ChainFrame { readonly locals: IrLocal[] = []; readonly params: IrParam[] = []; private n = 0; - constructor(private readonly loc: SrcLoc) {} + constructor(private readonly loc: SrcLoc) { + super(); + } local(base: string, type: IrType, mutable: boolean): string { const id = `${base}.${this.n++}`; this.locals.push({ id, name: base, type, mutable }); diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 222cb89f9..7896da578 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -645,8 +645,7 @@ function extendsParameter( info: GenericFnInfo, ): boolean { const params = new Set(); - const fn = info.decl as { parameters?: { nodes: readonly ts.ParameterDeclaration[] } }; - for (const param of fn.parameters?.nodes ?? []) { + for (const param of info.decl.parameters.nodes) { const symbol = ts.isIdentifier(param.name) ? lowerer.checker.getSymbolAtLocation(param.name) : undefined; @@ -672,8 +671,7 @@ function declaredInside(node: ts.Node, owner: ts.Node): boolean { * return gives anything else (or nothing). Nested functions and classes keep * their own returns. */ function returnedLocalClass(lowerer: Lowerer, info: GenericFnInfo): ts.ClassLikeDeclaration | null { - const fn = info.decl as ts.Node & { body?: ts.Node }; - const body = fn.body; + const body = info.decl.body; if (!body) return null; const classOf = (node: ts.Expression): ts.ClassLikeDeclaration | null => { let expr = node; diff --git a/packages/compiler/src/frontend/lowering/instantiation-path.test.ts b/packages/compiler/src/frontend/lowering/instantiation-path.test.ts index a31617106..267b402bc 100644 --- a/packages/compiler/src/frontend/lowering/instantiation-path.test.ts +++ b/packages/compiler/src/frontend/lowering/instantiation-path.test.ts @@ -1,19 +1,23 @@ import { expect, test } from "vitest"; +import type * as ts from "../ts7/adapter.js"; import { extendInstantiationPath, MAX_INSTANTIATION_RECURSION } from "./instantiation-path.js"; +/** A stand-in declaration: paths compare declarations by identity only. */ +const node = (): ts.Node => ({}) as ts.Node; + test("independent declarations and roots do not consume a recursive budget", () => { - const declaration = {}; + const declaration = node(); for (let i = 0; i < MAX_INSTANTIATION_RECURSION * 2; i++) expect(extendInstantiationPath(null, declaration)).not.toBeNull(); let path = extendInstantiationPath(null, declaration)!; for (let i = 0; i < MAX_INSTANTIATION_RECURSION * 2; i++) - path = extendInstantiationPath(path, {})!; + path = extendInstantiationPath(path, node())!; expect(extendInstantiationPath(path, declaration)).not.toBeNull(); }); test("bounds fresh recursive demands across intervening declarations", () => { - const first = {}; - const second = {}; + const first = node(); + const second = node(); let path = extendInstantiationPath(null, first)!; for (let i = 1; i < MAX_INSTANTIATION_RECURSION; i++) { path = extendInstantiationPath(path, second)!; diff --git a/packages/compiler/src/frontend/lowering/instantiation-path.ts b/packages/compiler/src/frontend/lowering/instantiation-path.ts index d79760988..b4a8375f6 100644 --- a/packages/compiler/src/frontend/lowering/instantiation-path.ts +++ b/packages/compiler/src/frontend/lowering/instantiation-path.ts @@ -1,7 +1,9 @@ +import type * as ts from "../ts7/adapter.js"; + /** Queued bodies preserve their demand ancestry. Independent concrete uses * do not consume a recursion budget; only fresh instances on one path do. */ export interface InstantiationPath { - declaration: object; + declaration: ts.Node; parent: InstantiationPath | null; } @@ -9,7 +11,7 @@ export const MAX_INSTANTIATION_RECURSION = 100; export function extendInstantiationPath( parent: InstantiationPath | null, - declaration: object, + declaration: ts.Node, ): InstantiationPath | null { let repetitions = 0; for (let path = parent; path; path = path.parent) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3c74cfbad..ae2734ad4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3694,7 +3694,7 @@ export class Lowerer { positionOf: (node) => this.positions.of(node), shapes: this.shapes, unions: this.unions, - classNamer: this.classNamer, + classNamer: (decl) => this.classNamer(decl), resolveTypeParam: this.typeParamResolver, resolveTypeParamTs: this.typeParamTsResolver, isErasing: () => this.erasingDepth > 0, @@ -3775,8 +3775,8 @@ export class Lowerer { }, mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened), classImplementedProtocol: (type) => this.classImplementedProtocol(type), - isStdlibFile: this.isStdlibFile, - isNpmFile: this.isNpmFile, + isStdlibFile: (sf) => this.isStdlibFile(sf), + isNpmFile: (sf) => this.isNpmFile(sf), // The declaration-typed boundary's generated types (decl-projection.ts) // describe data, like an explicitly mapped external type surface. isExternalTypeFile: (sf) => @@ -8002,16 +8002,24 @@ export class Lowerer { lower: () => IrFunction | null; } >(); - const bodyRoots = (...roots: (ts.Node | undefined | null)[]): ts.Node[] => - roots.filter((root) => root !== undefined && root !== null); - const functionRoots = (decl: ts.FunctionLikeDeclaration): ts.Node[] => - bodyRoots(...decl.parameters.nodes.map((param) => param.initializer), decl.body); - const classCtorRoots = (info: ClassInfo): ts.Node[] => - bodyRoots( - ...(info.ctor?.parameters?.nodes ?? []).map((param) => param.initializer), - info.ctor?.body, - ...info.fieldOrder.map((field) => field.initializer), - ); + // Roots are collected with plain pushes: spread arguments into a rest + // parameter pass the nodes as dynamic values. + const addRoot = (roots: ts.Node[], root: ts.Node | undefined | null): void => { + if (root !== undefined && root !== null) roots.push(root); + }; + const functionRoots = (decl: ts.FunctionLikeDeclaration): ts.Node[] => { + const roots: ts.Node[] = []; + for (const param of decl.parameters.nodes) addRoot(roots, param.initializer); + addRoot(roots, decl.body); + return roots; + }; + const classCtorRoots = (info: ClassInfo): ts.Node[] => { + const roots: ts.Node[] = []; + for (const param of info.ctor?.parameters?.nodes ?? []) addRoot(roots, param.initializer); + addRoot(roots, info.ctor?.body); + for (const field of info.fieldOrder) addRoot(roots, field.initializer); + return roots; + }; const classMemberRoots = (info: ClassInfo): ts.Node[] => { const roots = classCtorRoots(info); for (const { member } of this.classMethodMembers(info)) roots.push(...functionRoots(member)); @@ -8188,7 +8196,8 @@ export class Lowerer { register(`%${cName}.set:${prop}`, [], () => this.throwingSetterFn(info, prop)); } }; - for (const name of collectionEdges) this.onEdge(name); + const onEdge = this.onEdge; + for (const name of collectionEdges) onEdge(name); // Generic instances queued by the bodies above lower here (an instance // body fires edges of its own and can queue further instances of // either kind — function instances and class instantiations drain to @@ -8323,7 +8332,8 @@ export class Lowerer { // profile-mapped exports are called from outside the graph, so they // seed the worklist beside the init bodies. Unknown names are inert // (the export-map resolution reports them as SC4002 later). - for (const root of extraRoots ?? []) this.onEdge?.(root); + const rootEdge = this.onEdge; + for (const root of extraRoots ?? []) rootEdge?.(root); const shuffle = lowerShuffle(); const drainUnits = (): void => { while (queue.length > 0) { @@ -8441,7 +8451,9 @@ export class Lowerer { /** Reachability hook (see emitReachable): fires when lowering resolves a * reference to a lowerable body. */ noteEdge(name: string): void { - if (this.onEdge) this.onEdge(name); + // Called as a plain function: the instance is not its receiver. + const onEdge = this.onEdge; + if (onEdge) onEdge(name); } /** A subclass constructor's super() edge: the base constructor runs, but @@ -11827,12 +11839,15 @@ export class Lowerer { /** Method-like members with their collected method-map names. Body * lowering omits abstract declarations; ABI analysis must include them - * because an abstract-typed call selects the same virtual slot. */ - *classMethodMembers( + * because an abstract-typed call selects the same virtual slot. An array, + * not a generator: a generator's yielded values are dynamic values, which + * would make every syntax node one. */ + classMethodMembers( info: ClassInfo, includeAbstract = false, - ): Generator<{ mName: string; member: ts.MethodDeclaration | ts.AccessorDeclaration }> { - if (!info.decl) return; // builtin error classes: runtime-provided bodies + ): { mName: string; member: ts.MethodDeclaration | ts.AccessorDeclaration }[] { + const members: { mName: string; member: ts.MethodDeclaration | ts.AccessorDeclaration }[] = []; + if (!info.decl) return members; // builtin error classes: runtime-provided bodies for (const member of info.decl.members.nodes) { const fnLike = ts.isMethodDeclaration(member) || ts.isGetAccessor(member) || ts.isSetAccessor(member) @@ -11856,8 +11871,9 @@ export class Lowerer { : `${ts.isGetAccessor(fnLike) ? "get" : "set"}:${baseName}`; const signature = info.methods.get(mName); if (!signature || (!fnLike.body && !(includeAbstract && signature.abstract))) continue; - yield { mName, member: fnLike }; + members.push({ mName, member: fnLike }); } + return members; } lowerClassCtor(info: ClassInfo): IrFunction { diff --git a/packages/compiler/src/frontend/lowering/site-dependencies.test.ts b/packages/compiler/src/frontend/lowering/site-dependencies.test.ts index ca5affcbe..0580a52ee 100644 --- a/packages/compiler/src/frontend/lowering/site-dependencies.test.ts +++ b/packages/compiler/src/frontend/lowering/site-dependencies.test.ts @@ -1,9 +1,13 @@ import { expect, test } from "vitest"; +import type * as ts from "../ts7/adapter.js"; import { SiteDependencies } from "./site-dependencies.js"; +/** A stand-in node fact: facts are compared by identity. */ +const node = (): ts.Node => ({}) as ts.Node; + test("a changed fact makes the sites that read it stale", () => { const dependencies = new SiteDependencies(3); - const a = {}; + const a = node(); const b = "key"; dependencies.begin(0); dependencies.read(a); @@ -20,8 +24,8 @@ test("a changed fact makes the sites that read it stale", () => { test("a visit that changes a fact makes every reader of its facts stale", () => { const dependencies = new SiteDependencies(3); - const shared = {}; - const own = {}; + const shared = node(); + const own = node(); dependencies.begin(0); dependencies.read(shared); dependencies.end(false); @@ -37,7 +41,7 @@ test("a visit that changes a fact makes every reader of its facts stale", () => test("reads outside a visit are not recorded", () => { const dependencies = new SiteDependencies(1); - const fact = {}; + const fact = node(); dependencies.read(fact); dependencies.changed(fact); expect(dependencies.isStale(0)).toBe(false); @@ -45,7 +49,7 @@ test("reads outside a visit are not recorded", () => { test("a site visited again is listed once per fact", () => { const dependencies = new SiteDependencies(3); - const fact = {}; + const fact = node(); for (const site of [0, 2, 1, 2, 0]) { dependencies.begin(site); dependencies.read(fact); diff --git a/packages/compiler/src/frontend/lowering/site-dependencies.ts b/packages/compiler/src/frontend/lowering/site-dependencies.ts index da8531e48..868531353 100644 --- a/packages/compiler/src/frontend/lowering/site-dependencies.ts +++ b/packages/compiler/src/frontend/lowering/site-dependencies.ts @@ -1,3 +1,5 @@ +import type * as ts from "../ts7/adapter.js"; + /** The facts each site of a fixed-point scan read on its last visit, so that * a pass visits again only the sites whose facts changed since. * @@ -13,14 +15,19 @@ * that with a pass over every site once a pass changes nothing, and resumes * when that pass changes a fact: a fact this tracking misses costs passes, * not a different result. */ +/** A fact a site reads: a symbol, a syntax node, or the name of a derived + * fact. A typed union keeps nodes and symbols native values; as `unknown` + * every syntax node would become a dynamic value. */ +export type SiteFact = ts.Node | ts.Symbol | string; + export class SiteDependencies { /** Each fact's entry: the visit that last read it, so that a visit lists a * fact once, followed by the sites that read it, ascending. */ - private readonly readers = new Map(); + private readonly readers = new Map(); private readonly stale: Uint8Array; private site = -1; private visit = 0; - private facts: unknown[] = []; + private facts: SiteFact[] = []; constructor(siteCount: number) { this.stale = new Uint8Array(siteCount); @@ -46,7 +53,7 @@ export class SiteDependencies { } /** The site being visited read `fact`. */ - read(fact: unknown): void { + read(fact: SiteFact): void { if (this.site < 0) return; const entry = this.readers.get(fact); if (entry === undefined) { @@ -72,7 +79,7 @@ export class SiteDependencies { } /** `fact` changed: every site that read it is visited again. */ - changed(fact: unknown): void { + changed(fact: SiteFact): void { const entry = this.readers.get(fact); if (entry !== undefined) for (let i = 1; i < entry.length; i++) this.stale[entry[i]!] = 1; } diff --git a/packages/compiler/src/frontend/lowering/string-index-bounds.ts b/packages/compiler/src/frontend/lowering/string-index-bounds.ts index af1b00ec6..6418aed76 100644 --- a/packages/compiler/src/frontend/lowering/string-index-bounds.ts +++ b/packages/compiler/src/frontend/lowering/string-index-bounds.ts @@ -182,11 +182,28 @@ export class StringIndexBounds { private readonly identifiers = new Map>(); constructor( - private readonly symbolAt: (node: ts.Node) => ts.Symbol | null, - private readonly declarationOf: (symbol: ts.Symbol) => ts.Node | undefined, - private readonly isString: (node: ts.Expression) => boolean, + private readonly lookupSymbol: (node: ts.Node) => ts.Symbol | null, + private readonly lookupDeclaration: (symbol: ts.Symbol) => ts.Node | undefined, + private readonly stringTyped: (node: ts.Expression) => boolean, ) {} + // The lookups are called as plain functions: the instance is not their + // receiver. + private symbolAt(node: ts.Node): ts.Symbol | null { + const lookup = this.lookupSymbol; + return lookup(node); + } + + private declarationOf(symbol: ts.Symbol): ts.Node | undefined { + const lookup = this.lookupDeclaration; + return lookup(symbol); + } + + private isString(node: ts.Expression): boolean { + const test = this.stringTyped; + return test(node); + } + /** True when `read` (an element access on a string receiver) is proven * to name an existing code unit. */ inBounds(read: ts.ElementAccessExpression): boolean { diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index eed02c95c..b00e14aa0 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -254,13 +254,18 @@ function aliasedPackageName(pkgDir: string): string | null { export function npmStaticTransformPkgJson(pkg: Record): void { delete pkg["types"]; delete pkg["typings"]; - const transform = (v: unknown): void => { - if (v === null || typeof v !== "object") return; - if (Array.isArray(v)) { - for (const item of v) transform(item); - return; - } - const obj = v as Record; + const transform = (v: unknown): unknown => { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(transform); + // The result is a new object, built in key order; the first entry of a + // key wins. + const entries: [string, unknown][] = []; + const seen = new Set(); + const add = (key: string, value: unknown): void => { + if (seen.has(key)) return; + seen.add(key); + entries.push([key, value]); + }; // 0. The "node" condition HOISTS: Node's runtime resolution matches it // (yaml's `{ types, node: "./dist/index.js", default: "./browser/ // index.js" }` runs the node dist; the browser dist is a DIFFERENT @@ -270,24 +275,18 @@ export function npmStaticTransformPkgJson(pkg: Record): void { // under import+require (either matcher finds it there), an object's // entries splice inline — earlier real keys keep winning, exactly // Node's first-match-in-key-order rule. - if (obj["node"] !== undefined) { - const entries = Object.entries(obj); - for (const k of Object.keys(obj)) delete obj[k]; - for (const [k, val] of entries) { - if (k !== "node") { - if (!(k in obj)) obj[k] = val; - continue; - } - const inner: Record = - typeof val === "string" - ? { import: val, require: val } - : val !== null && typeof val === "object" && !Array.isArray(val) - ? (val as Record) - : {}; - for (const [ik, iv] of Object.entries(inner)) { - if (!(ik in obj)) obj[ik] = iv; - } + for (const [key, value] of Object.entries(v as Record)) { + if (key !== "node") { + add(key, value); + continue; } + const inner: Record = + typeof value === "string" + ? { import: value, require: value } + : value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + for (const [innerKey, innerValue] of Object.entries(inner)) add(innerKey, innerValue); } // 1. The types claims go AFTER the hoist: a node object's own // "types" key (uuid's `node: { types: "./dist/index.d.ts", default: @@ -295,15 +294,17 @@ export function npmStaticTransformPkgJson(pkg: Record): void { // in place tsgo follows it, maps the hidden declaration to its // same-stem JS (dist/index.js, the browser build) and disagrees with // this resolver's runtime answer. - delete obj["types"]; - delete obj["typings"]; - for (const value of Object.values(obj)) transform(value); + return Object.fromEntries( + entries + .filter(([key]) => key !== "types" && key !== "typings") + .map(([key, value]): [string, unknown] => [key, transform(value)]), + ); }; - if (pkg["exports"] !== undefined) transform(pkg["exports"]); + if (pkg["exports"] !== undefined) pkg["exports"] = transform(pkg["exports"]); // The "imports" field maps "#" specifiers with the same condition objects // (chalk 5's `"#supports-color": { node, default }`), so Node's "node" // target hoists there too. - if (pkg["imports"] !== undefined) transform(pkg["imports"]); + if (pkg["imports"] !== undefined) pkg["imports"] = transform(pkg["imports"]); } /** npmStaticTransformPkgJson over a package.json TEXT (the fs shadow's diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 19728d6ce..14b366bfa 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -52,8 +52,10 @@ export class FrontendServices { } private sourceParser(): Ts7SourceParser { this.ensureOpen(); + // Called as a plain function: the instance is not its receiver. + const createApi = this.createApi; return (this.parser ??= new Ts7SourceParser( - (options) => this.createApi({ ...options, collectTiming: false }), + (options) => createApi({ ...options, collectTiming: false }), this.cwd, )); } @@ -62,9 +64,10 @@ export class FrontendServices { } evaluateComptime(source: string, timeoutMs: number): unknown { this.ensureOpen(); - if (this.comptimeEvaluator === undefined) + const evaluate = this.comptimeEvaluator; + if (evaluate === undefined) throw new Error("this compiler host does not provide compile-time evaluation"); - return this.comptimeEvaluator(source, timeoutMs); + return evaluate(source, timeoutMs); } createProgramHost(options?: Ts7HostOptions): Ts7Host { this.ensureOpen(); diff --git a/packages/compiler/src/frontend/ts7/semantic-model.ts b/packages/compiler/src/frontend/ts7/semantic-model.ts index d3d5fe61a..1a6560348 100644 --- a/packages/compiler/src/frontend/ts7/semantic-model.ts +++ b/packages/compiler/src/frontend/ts7/semantic-model.ts @@ -64,7 +64,9 @@ export interface SemanticQuery { export class SemanticProgram { constructor(private readonly sourceFile: (path: string) => AstNode | undefined) {} getSourceFile(path: string): AstNode | undefined { - return this.sourceFile(path); + // Called as a plain function: the instance is not its receiver. + const sourceFile = this.sourceFile; + return sourceFile(path); } } diff --git a/packages/compiler/src/frontend/ts7/source-parser.ts b/packages/compiler/src/frontend/ts7/source-parser.ts index f9029774c..bbb6cae7a 100644 --- a/packages/compiler/src/frontend/ts7/source-parser.ts +++ b/packages/compiler/src/frontend/ts7/source-parser.ts @@ -42,7 +42,9 @@ export class Ts7SourceParser { } private connect(): Ts7Api { - return this.createApi({ + // Called as a plain function: the instance is not its receiver. + const createApi = this.createApi; + return createApi({ cwd: this.cwd, fs: { readFile: (path) => this.files.get(tsgoPath(path)) ?? null, diff --git a/packages/compiler/test/keyed-method-writes.test.ts b/packages/compiler/test/keyed-method-writes.test.ts new file mode 100644 index 000000000..407530945 --- /dev/null +++ b/packages/compiler/test/keyed-method-writes.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; +import { everyStmtList } from "../src/ir/traverse.js"; + +async function lowerSource(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-keyed-writes-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Calls in `run` that go through a dynamic value. */ +function dynamicCalls(mod: IrModule): number { + const fn = mod.functions.find((f) => f.name === "run"); + if (!fn) throw new Error("no run function"); + let n = 0; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "dynCall" || e.kind === "dynInvoke") n++; + return true; + }, + stmt: () => true, + }); + return n; +} + +const shape = ` +type Step = "scale" | "clamp"; +class Transform { + scale(x: number): number { + return x * 2; + } + clamp(x: number): number { + return x < 0 ? 0 : x; + } +} +`; + +test("element writes of data under method names keep the methods native", async () => { + const mod = await lowerSource(`${shape} +const enabled: Record = { scale: false, clamp: false }; +function enable(name: Step): void { + enabled[name] = true; +} +const totals: Record = {}; +function run(t: Transform): number { + enable("scale"); + totals["clamp"] = 1; + return t.clamp(t.scale(3)); +} +console.log(run(new Transform())); +`); + expect(dynamicCalls(mod)).toBe(0); +}); + +test("element writes through untyped receivers can still replace a method", async () => { + const mod = await lowerSource(`${shape} +function replace(target: object, name: Step): void { + (target as any)[name] = (x: number) => x + 100; +} +function run(t: Transform): number { + replace(t, "scale"); + return t.clamp(t.scale(3)); +} +console.log(run(new Transform())); +`); + expect(dynamicCalls(mod)).toBeGreaterThan(0); +}); From d68a7059758bf6e155dc9db2e2847afef836849a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 22:31:08 -0500 Subject: [PATCH 2/9] Read a union's discriminant once in checked casts A checked cast into a union whose discriminant has four or more string literals reads the discriminant field once (scr_dyn_discriminant_case) and then tests only the arms whose literal it holds and the arms without a literal, in the union's arm order. Every other arm's literal test would fail, so the arm chosen and the error for a value no arm matches are unchanged. A typed capsule, an accessor or an inherited discriminant still takes the tests arm by arm. Unions with fewer literals emit the same code as before. --- benchmarks/runtime/README.md | 1 + benchmarks/runtime/workloads/json-unions.json | 6 + benchmarks/runtime/workloads/json-unions.ts | 155 +++++++++++++++++ packages/compiler/src/backend/llvm/dyn.ts | 160 ++++++++++++++++-- .../compiler/test/chunked-emission.test.ts | 8 +- .../6305-json-discriminant-dispatch.ts.json | 6 + packages/runtime/src/scr_dyn.h | 8 + packages/runtime/src/scr_json.c | 18 ++ .../corpus/6305-json-discriminant-dispatch.ts | 123 ++++++++++++++ .../6305-json-discriminant-dispatch.ts.json | 1 + tests/harness/dyncheck.test.ts | 48 ++++++ 11 files changed, 519 insertions(+), 15 deletions(-) create mode 100644 benchmarks/runtime/workloads/json-unions.json create mode 100644 benchmarks/runtime/workloads/json-unions.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json create mode 100644 tests/corpus/6305-json-discriminant-dispatch.ts create mode 100644 tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 4f152e4bb..001880a38 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | +| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | | `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes | diff --git a/benchmarks/runtime/workloads/json-unions.json b/benchmarks/runtime/workloads/json-unions.json new file mode 100644 index 000000000..925e8457b --- /dev/null +++ b/benchmarks/runtime/workloads/json-unions.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/json-unions.ts", + "args": [ + "6" + ] +} diff --git a/benchmarks/runtime/workloads/json-unions.ts b/benchmarks/runtime/workloads/json-unions.ts new file mode 100644 index 000000000..0f684c35f --- /dev/null +++ b/benchmarks/runtime/workloads/json-unions.ts @@ -0,0 +1,155 @@ +// JSON text parsed into discriminated unions: an event log with eight kinds +// of event and a nested expression tree with nine kinds of node, cast with +// `JSON.parse(text) as T`, then aggregated and evaluated. Argument: the +// number of rounds. +type Event = + | { kind: "click"; target: string; x: number; y: number } + | { key: string; kind: "key"; repeat: boolean } + | { kind: "scroll"; offset: number } + | { kind: "resize"; height: number; width: number } + | { kind: "focus"; target: string } + | { kind: "blur"; target: string } + | { kind: "load"; ms: number; url: string } + | { code: number; kind: "error"; message: string }; + +type Expr = + | { kind: "num"; value: number } + | { kind: "var"; name: string } + | { kind: "neg"; operand: Expr } + | { kind: "add"; left: Expr; right: Expr } + | { kind: "sub"; left: Expr; right: Expr } + | { kind: "mul"; left: Expr; right: Expr } + | { kind: "max"; items: Expr[] } + | { else: Expr; kind: "if"; test: Expr; then: Expr } + | { body: Expr; kind: "let"; name: string; value: Expr }; + +let seed = 12345; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +const targets = ["menu", "search", "button", "list", "footer"]; +function eventText(i: number): string { + const target = targets[i % targets.length]!; + switch (i % 8) { + case 0: + return `{"kind":"click","target":"${target}","x":${i % 640},"y":${i % 480}}`; + case 1: + return `{"key":"k${i % 26}","kind":"key","repeat":${i % 3 === 0}}`; + case 2: + return `{"kind":"scroll","offset":${(i * 7) % 2000}}`; + case 3: + return `{"height":${400 + (i % 300)},"kind":"resize","width":${600 + (i % 500)}}`; + case 4: + return `{"kind":"focus","target":"${target}"}`; + case 5: + return `{"kind":"blur","target":"${target}"}`; + case 6: + return `{"kind":"load","ms":${i % 900},"url":"/page/${i % 50}"}`; + default: + return `{"code":${400 + (i % 100)},"kind":"error","message":"failed ${i % 13}"}`; + } +} + +const names = ["a", "b", "c", "d"]; +function exprText(depth: number): string { + const pick = depth <= 0 ? Math.floor(random() * 2) : Math.floor(random() * 9); + switch (pick) { + case 0: + return `{"kind":"num","value":${Math.floor(random() * 100)}}`; + case 1: + return `{"kind":"var","name":"${names[Math.floor(random() * names.length)]!}"}`; + case 2: + return `{"kind":"neg","operand":${exprText(depth - 1)}}`; + case 3: + case 4: + case 5: { + const kind = pick === 3 ? "add" : pick === 4 ? "sub" : "mul"; + return `{"kind":"${kind}","left":${exprText(depth - 1)},"right":${exprText(depth - 1)}}`; + } + case 6: + return `{"items":[${exprText(depth - 1)},${exprText(depth - 2)},${exprText(depth - 2)}],"kind":"max"}`; + case 7: + return `{"else":${exprText(depth - 2)},"kind":"if","test":${exprText(depth - 1)},"then":${exprText(depth - 2)}}`; + default: + return `{"body":${exprText(depth - 1)},"kind":"let","name":"${names[Math.floor(random() * names.length)]!}","value":${exprText(depth - 2)}}`; + } +} + +function evaluate(e: Expr, env: Map): number { + switch (e.kind) { + case "num": + return e.value; + case "var": + return env.get(e.name) ?? 1; + case "neg": + return -evaluate(e.operand, env); + case "add": + return (evaluate(e.left, env) + evaluate(e.right, env)) % 1000003; + case "sub": + return (evaluate(e.left, env) - evaluate(e.right, env)) % 1000003; + case "mul": + return (evaluate(e.left, env) * evaluate(e.right, env)) % 1000003; + case "max": { + let best = -Infinity; + for (const item of e.items) best = Math.max(best, evaluate(item, env)); + return best; + } + case "if": + return evaluate(e.test, env) > 0 ? evaluate(e.then, env) : evaluate(e.else, env); + case "let": { + const inner = new Map(env); + inner.set(e.name, evaluate(e.value, env)); + return evaluate(e.body, inner); + } + } +} + +const rounds = Number(process.argv[2] ?? "6"); +const eventsText = `[${Array.from({ length: 60000 }, (_, i) => eventText(i)).join(",")}]`; +const treesText = `[${Array.from({ length: 400 }, () => exprText(9)).join(",")}]`; +let clicks = 0; +let keys = 0; +let scrolled = 0; +let area = 0; +let loadMs = 0; +let errors = 0; +let focus = 0; +let total = 0; +for (let round = 0; round < rounds; round++) { + const events = JSON.parse(eventsText) as Event[]; + for (const event of events) { + switch (event.kind) { + case "click": + clicks += event.x + event.y; + break; + case "key": + if (event.repeat) keys++; + break; + case "scroll": + scrolled += event.offset; + break; + case "resize": + area += (event.width * event.height) % 1000; + break; + case "focus": + case "blur": + focus += event.target.length; + break; + case "load": + loadMs += event.ms; + break; + case "error": + errors += event.code % 7; + break; + } + } + const trees = JSON.parse(treesText) as Expr[]; + const env = new Map([ + ["a", 3], + ["b", 5], + ]); + for (const tree of trees) total = (total + evaluate(tree, env)) % 1000003; +} +console.log(clicks, keys, scrolled, area, loadMs, errors, focus, total); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index d90239fc3..3d7533f22 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -193,6 +193,11 @@ function builtinStreamKind(className: string): number | null { return kinds[className] ?? null; } +/** Unions whose discriminant has fewer string literals keep one literal test + * per arm (dispatchValues): one runtime lookup costs about what a few + * literal tests do. */ +const DISCRIMINANT_DISPATCH_VALUES = 4; + export class LlDyn { private readonly dynMatchers = new Map(); private readonly dynBuilders = new Map(); @@ -413,6 +418,86 @@ export class LlDyn { return order; } + /** The string literals of `def`'s discriminant when its arms are chosen + * by reading the discriminant once (unionArmsByDiscriminant), or null + * when the union keeps one literal test per arm: some literal is not a + * string, or there are fewer than DISCRIMINANT_DISPATCH_VALUES. */ + private dispatchValues(def: IrUnionDef): string[] | null { + const values: string[] = []; + for (const guard of def.discriminant?.cases ?? []) { + if (guard.values.length === 0) return null; + for (const value of guard.values) { + if (typeof value !== "string") return null; + if (!values.includes(value)) values.push(value); + } + } + return values.length < DISCRIMINANT_DISPATCH_VALUES ? null : values; + } + + /** Branches to `hits[tag]` for the first arm in `order` that `%d` matches + * (unionArmMatch), or to `none` when no arm does, for + * a union whose discriminant literals are `values` (dispatchValues). The + * discriminant is read once (scr_dyn_discriminant_case, against the + * constant `table`), and only the arms whose literal it holds and the arms + * without a literal are tested, in `order`: every other arm's literal test + * fails. A value the runtime cannot classify that way (a typed capsule, an + * accessor, an inherited discriminant) takes the tests arm by arm. */ + private unionArmsByDiscriminant( + B: BlockBuilder, + def: IrUnionDef, + values: readonly string[], + order: readonly number[], + hits: readonly string[], + none: string, + preserveRefs: boolean, + table: string, + ): void { + const discriminant = def.discriminant!; + const literalsOf = (tag: number): readonly (string | number | boolean)[] | null => + discriminant.cases.find((candidate) => candidate.tag === tag)?.values ?? null; + const tryArms = (tags: readonly number[], literal: boolean): void => { + if (tags.length === 0) { + B.br(none); + return; + } + tags.forEach((tag, index) => { + const next = index === tags.length - 1 ? none : B.newLabel("du.c.n"); + if (literal) this.unionArmMatch(B, def, tag, hits[tag]!, next, preserveRefs); + else this.unionArmStructure(B, def, tag, hits[tag]!, next, preserveRefs); + if (next !== none) B.startBlock(next); + }); + }; + const armsFor = (value: string | null): number[] => + order.filter((tag) => { + const literals = literalsOf(tag); + return literals === null || (value !== null && literals.includes(value)); + }); + const host = this.host; + this.defs.push( + `${table} = private unnamed_addr constant [${values.length} x ptr] [${values.map((value) => `ptr ${host.internLiteral(value)}`).join(", ")}]`, + ``, + ); + host.declare(`declare i32 @scr_dyn_discriminant_case(ptr, ptr, ptr, ${host.sizeType})`); + const index = B.tmp(); + B.line( + `${index} = call i32 @scr_dyn_discriminant_case(ptr %d, ptr ${host.internLiteral(discriminant.field)}, ptr ${table}, ${host.sizeType} ${values.length})`, + ); + const tests = B.newLabel("du.tests"); + const unmatched = B.newLabel("du.none"); + const cases = values.map(() => B.newLabel("du.case")); + B.terminate( + `switch i32 ${index}, label %${tests} [ i32 -1, label %${unmatched} ${cases.map((label, k) => `i32 ${k}, label %${label}`).join(" ")} ]`, + ); + values.forEach((value, k) => { + B.startBlock(cases[k]!); + tryArms(armsFor(value), false); + }); + B.startBlock(unmatched); + tryArms(armsFor(null), false); + B.startBlock(tests); + tryArms(order, true); + } + /** Branch to a variant only after its literal discriminator and complete * structure match. Use the same path for predicates and builders. */ private unionArmMatch( @@ -450,6 +535,18 @@ export class LlDyn { }); B.startBlock(structure); } + this.unionArmStructure(B, def, tag, yes, no, preserveRefs); + } + + /** The part of unionArmMatch past the arm's literal test. */ + private unionArmStructure( + B: BlockBuilder, + def: IrUnionDef, + tag: number, + yes: string, + no: string, + preserveRefs: boolean, + ): void { const matches = B.tmp(); B.line( `${matches} = call zeroext i1 @${this.dynMatchHelper(def.arms[tag]!, preserveRefs)}(ptr %d)`, @@ -1275,11 +1372,26 @@ export class LlDyn { ); // Arms in canonical order; any full match answers true. const yes = B.newLabel("dm.y"); - for (let tag = 0; tag < def.arms.length; tag++) { - const ln = B.newLabel("dm.n"); - this.unionArmMatch(B, def, tag, yes, ln, preserveRefs); - B.startBlock(ln); - } + const values = this.dispatchValues(def); + if (values !== null) { + const no = B.newLabel("dm.n"); + this.unionArmsByDiscriminant( + B, + def, + values, + def.arms.map((_, tag) => tag), + def.arms.map(() => yes), + no, + preserveRefs, + `@${name}.cases`, + ); + B.startBlock(no); + } else + for (let tag = 0; tag < def.arms.length; tag++) { + const ln = B.newLabel("dm.n"); + this.unionArmMatch(B, def, tag, yes, ln, preserveRefs); + B.startBlock(ln); + } B.terminate(`ret i1 false`); B.startBlock(yes); B.terminate(`ret i1 true`); @@ -2459,12 +2571,8 @@ export class LlDyn { ); // Arms in unionArmOrder, first FULL match wins. The matched arm's // builder can no longer fail. - for (const i of this.unionArmOrder(def)) { + const build = (i: number): void => { const arm = def.arms[i]!; - const lHit = B.newLabel("dcu.h"); - const lNext = B.newLabel("dcu.n"); - this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); - B.startBlock(lHit); if (arm.kind === "undefinedT" || arm.kind === "nullT") { // A matched unit arm builds nothing: THE interned immortal // instance (rc == SIZE_MAX — no retain owed). @@ -2503,8 +2611,36 @@ export class LlDyn { ); B.terminate(`ret ptr ${u}`); } - B.startBlock(lNext); - } + }; + const order = this.unionArmOrder(def); + const values = this.dispatchValues(def); + if (values !== null) { + const hits = def.arms.map(() => B.newLabel("dcu.h")); + const unmatched = B.newLabel("dcu.n"); + this.unionArmsByDiscriminant( + B, + def, + values, + order, + hits, + unmatched, + preserveRefs, + `@${name}.cases`, + ); + for (const i of order) { + B.startBlock(hits[i]!); + build(i); + } + B.startBlock(unmatched); + } else + for (const i of order) { + const lHit = B.newLabel("dcu.h"); + const lNext = B.newLabel("dcu.n"); + this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); + B.startBlock(lHit); + build(i); + B.startBlock(lNext); + } if (preserveRefs) { B.line( `call void @scr_dyn_check_fail(ptr %path, ptr ${host.cstr("reference with its original layout")}, ptr %d)`, diff --git a/packages/compiler/test/chunked-emission.test.ts b/packages/compiler/test/chunked-emission.test.ts index b4912c9f6..2aec1e49b 100644 --- a/packages/compiler/test/chunked-emission.test.ts +++ b/packages/compiler/test/chunked-emission.test.ts @@ -9,9 +9,10 @@ import { inlineRcOption } from "../src/backend/llvm/shapes.js"; // Programs whose function bodies request module-level helpers and tables // that the chunk journal (emit-journal.ts) carries: checked-dynamic -// conversions, inspection, publication walkers, live record views, stream -// adapters, regex instances, colliding literal digests, interned C strings, -// speculative loop sites, typed message listeners and readers. +// conversions (with their discriminant tables), inspection, publication +// walkers, live record views, stream adapters, regex instances, colliding +// literal digests, interned C strings, speculative loop sites, typed message +// listeners and readers. const PROGRAMS = [ "1003-json-parse-unions.ts", "114-live-record-delete.ts", @@ -21,6 +22,7 @@ const PROGRAMS = [ "4900-console-instances.ts", "5612-readable-from-iterables.cjs", "5702-publish-candidate-buffers.ts", + "6305-json-discriminant-dispatch.ts", "7321-speculative-loop-bail-sites.ts", "7360-worker-typed-records.ts", "node24-text-utilities-coercion.js", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json new file mode 100644 index 000000000..b83ada276 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6305-json-discriminant-dispatch.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index efb83d9fc..d46764d3d 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -677,6 +677,14 @@ void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry bool scr_dyn_field_eq_str(const ScrDyn *d, const ScrStr *key, const ScrStr *value); bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value); bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value); +/* The case a union's checked match takes from its string discriminant: the + * index in `values` of the string `d`'s own data property `key` holds, or + * -1 when every literal test above fails (`d` is not an object, or its own + * data property `key` holds no string among `values`), or -2 when only the + * tests themselves can tell (a typed capsule, an accessor, or a property + * `d` does not own). Borrowed args. */ +int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values, + size_t count); /* Object.keys/values/entries over the checked-dynamic tree: JS own-key order (array-index * keys ascending first), dyn-array results (+1); values/entries RETAIN diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 580b816c4..0ba6ec28d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1422,6 +1422,24 @@ bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value) { return matches; } +int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values, + size_t count) { + if (!d) return -1; + if (d->kind == SCR_DYN_TYPED_REF) return -2; + if (d->kind != SCR_DYN_OBJ) return -1; + for (size_t i = 0; i < d->v.obj.len; i++) { + const ScrDynEntry *entry = &d->v.obj.entries[i]; + if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue; + if (entry->accessor) return -2; + const ScrDyn *member = entry->value; + if (!member || member->kind != SCR_DYN_STR) return -1; + for (size_t k = 0; k < count; k++) + if (scr_str_eq(member->v.str, values[k])) return (int32_t)k; + return -1; + } + return -2; +} + bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value) { ScrDyn *member = scr_dyn_discriminant(d, key); bool matches = member && member->kind == SCR_DYN_BOOL && member->v.b == value; diff --git a/tests/corpus/6305-json-discriminant-dispatch.ts b/tests/corpus/6305-json-discriminant-dispatch.ts new file mode 100644 index 000000000..ac4d992da --- /dev/null +++ b/tests/corpus/6305-json-discriminant-dispatch.ts @@ -0,0 +1,123 @@ +// JSON cast into a union with many string discriminant literals: the cast +// reads the discriminant once and tests only the arm whose literal it +// holds, plus the arms that are not records. An arm can list several +// literals, and values that are not objects reach the other arms in +// canonical order. Valid casts only (see 1002-json-parse-cast.ts); JSON keys +// and record fields alphabetical for stringify parity. + +type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add" | "mul"; left: Expr; right: Expr } + | { args: Expr[]; kind: "call"; name: string } + | { kind: "method"; name: string; target: Expr } + | { kind: "list"; items: Expr[] }; + +function show(e: Expr): string { + switch (e.kind) { + case "num": + return String(e.value); + case "str": + return JSON.stringify(e.text); + case "neg": + return `-(${show(e.operand)})`; + case "add": + return `(${show(e.left)} + ${show(e.right)})`; + case "mul": + return `(${show(e.left)} * ${show(e.right)})`; + case "call": + return `${e.name}(${e.args.map(show).join(", ")})`; + case "method": + return `${show(e.target)}.${e.name}()`; + case "list": + return `[${e.items.map(show).join(", ")}]`; + } +} + +function evaluate(e: Expr): number { + switch (e.kind) { + case "num": + return e.value; + case "str": + return e.text.length; + case "neg": + return -evaluate(e.operand); + case "add": + return evaluate(e.left) + evaluate(e.right); + case "mul": + return evaluate(e.left) * evaluate(e.right); + case "call": + return e.args.reduce((sum, arg) => sum + evaluate(arg), 0); + case "method": + return evaluate(e.target); + case "list": + return e.items.length; + } +} + +const source = + '{"kind":"add","left":{"kind":"mul","left":{"kind":"num","value":3},' + + '"right":{"kind":"neg","operand":{"kind":"num","value":4}}},' + + '"right":{"args":[{"kind":"str","text":"abc"},{"kind":"num","value":2}],"kind":"call","name":"sum"}}'; +const tree = JSON.parse(source) as Expr; +console.log(show(tree)); +console.log(evaluate(tree)); +console.log(JSON.stringify(tree) === source); + +const method = JSON.parse( + '{"kind":"method","name":"size","target":{"items":[{"kind":"num","value":1}],"kind":"list"}}', +) as Expr; +console.log(show(method), evaluate(method)); + +// A deep list of every arm, built from text. +const parts: string[] = []; +for (let i = 0; i < 200; i++) { + parts.push( + i % 3 === 0 + ? `{"kind":"num","value":${i}}` + : i % 3 === 1 + ? `{"kind":"neg","operand":{"kind":"num","value":${i}}}` + : `{"kind":"mul","left":{"kind":"num","value":${i}},"right":{"kind":"str","text":"xy"}}`, + ); +} +const list = JSON.parse(`{"items":[${parts.join(",")}],"kind":"list"}`) as Expr; +if (list.kind === "list") { + let total = 0; + for (const item of list.items) total += evaluate(item); + console.log(list.items.length, total); +} + +// Arms that are not records: a value that is not an object can only be one +// of them. +type Token = + | { kind: "ident"; name: string } + | { kind: "number"; value: number } + | { kind: "string"; value: string } + | { kind: "punct"; symbol: string } + | { kind: "comment"; line: number; text: string } + | string + | number; + +function describe(token: Token): string { + if (typeof token === "string") return `raw ${token}`; + if (typeof token === "number") return `count ${token}`; + switch (token.kind) { + case "ident": + return `ident ${token.name}`; + case "number": + return `number ${token.value}`; + case "string": + return `string ${JSON.stringify(token.value)}`; + case "punct": + return `punct ${token.symbol}`; + case "comment": + return `comment ${token.line}: ${token.text}`; + } +} + +const tokens = JSON.parse( + '[{"kind":"ident","name":"x"},{"kind":"punct","symbol":"="},{"kind":"number","value":42},' + + '{"kind":"string","value":"hi"},{"kind":"comment","line":3,"text":"note"},"spare",7]', +) as Token[]; +for (const token of tokens) console.log(describe(token)); diff --git a/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json b/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json new file mode 100644 index 000000000..7e2a9d53c --- /dev/null +++ b/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json @@ -0,0 +1 @@ +{ "plain": 5844, "san": 7609 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 6a2d4dc61..5559be970 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -396,6 +396,54 @@ console.log("unreachable", s.kind); expect(r.stderr).toContain("Uncaught TypeError: expected object | object at $, got object"); }); + test("a union read by its discriminant fails with every arm named, as arm-by-arm tests do", async () => { + // Five record arms with six literals, plus string and number arms: the + // check reads `kind` once and tests only the arm it names and the + // arms that are not records. + const r = await compileAndRun( + "union-discriminant-dispatch", + `type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add" | "mul"; left: Expr; right: Expr } + | { items: Expr[]; kind: "list" } + | string + | number; +const inputs = [ + '{"kind":"num"}', + '{"kind":"nope","value":1}', + '{"kind":7,"value":1}', + '{"value":1}', + '{"kind":"neg","operand":{"kind":"add","left":{"kind":"num","value":1},"right":{"kind":"str","text":5}}}', + 'true', + '{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}', +]; +for (const input of inputs) { + try { + console.log(JSON.stringify(JSON.parse(input) as Expr)); + } catch (error) { + console.log((error as Error).message); + } +} +`, + ); + const any = "number | object | object | object | object | object | string"; + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got boolean`, + '{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}', + "", + ].join("\n"), + ); + }); + test("nested paths point at the offending element", async () => { const r = await compileAndRun( "nested-path", From 36daee3f6187f755c758ac62647d3dbbde1528ce Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 23:16:58 -0500 Subject: [PATCH 3/9] Fix the CI failure --- scripts/zero-cost-compare.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 160e33438..9247585e5 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -394,7 +394,8 @@ export function dynamicTierSymbols(names) { * not yet traced to its typed root. bench/thread-slots, * bench/channel-messages and bench/worker-messages, added later, link them * through worker messages: - * node:worker_threads values are dyn objects. bench/dynamic-methods is + * node:worker_threads values are dyn objects. bench/json-unions, added + * later too, decodes JSON.parse trees. bench/dynamic-methods is * untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and * tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also * fails when a listed workload stops linking them, so remove a workload here @@ -414,6 +415,7 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/typed-array-scan-workers", "bench/log-summary", "bench/inventory-report", + "bench/json-unions", ]); /** Names of the functions (text and weak symbols) in `nm` output. Mach-O From 68a9dea3fa0f23154c6850b6acc5c5d29ce6cc09 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 00:42:45 -0500 Subject: [PATCH 4/9] Give the capped Linux test workers the heap they had before the cap --- .github/workflows/ci.yml | 7 +++++-- tests/harness/ci-test-plan.test.ts | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a38482a..2d3ecbf18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,13 +221,16 @@ jobs: # the whole runner did before (a sanitized shard peaks near 16 GB), and # when RAM and swap run out the kernel kills the scope's largest process. # OOMPolicy=continue leaves the rest of the scope running, so vitest - # reports the killed worker and the job log names the test. + # reports the killed worker and the job log names the test. Node sizes + # its default heap from the memory it may use, which the scope lowers + # to about 3.5 GB; the workers keep the 4 GB heap they had on the bare + # runner. - name: Tests (corpus and integration suites) run: | ulimit -c 0 if [ -d /run/systemd/system ]; then exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue \ - --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" node scripts/ci-test.mjs + --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" NODE_OPTIONS="--max-old-space-size=4096" node scripts/ci-test.mjs fi node scripts/ci-test.mjs diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index dd77039ca..6cee99370 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -299,7 +299,9 @@ test("pull requests run the full suite on Linux and the darwin suite on macOS", const linuxRun = linux.steps.at(-1)!.run as string; expect(linuxRun.startsWith("ulimit -c 0\n")).toBe(true); expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue"); - expect(linuxRun).toMatch(/-- env PATH="\$PATH" HOME="\$HOME" node scripts\/ci-test\.mjs\n/); + expect(linuxRun).toMatch( + /-- env PATH="\$PATH" HOME="\$HOME" NODE_OPTIONS="--max-old-space-size=4096" node scripts\/ci-test\.mjs\n/, + ); expect(linuxRun.endsWith("fi\nnode scripts/ci-test.mjs\n")).toBe(true); expect(macos.steps.at(-1)!.run).toBe("node scripts/ci-test.mjs"); for (const job of [linux, macos]) expect(job.strategy.matrix.flavor).toEqual(["plain", "san"]); From 70c2b8246d244d5274056c2fc851f8938f5b7c1d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 01:03:18 -0500 Subject: [PATCH 5/9] Share untyped module bindings that combine with strings and numbers A JavaScript module binding whose initializer joins an untyped value with a string, number or boolean through `||`, `&&`, `??` or `?:` (`export const APP_NAME = pkg.appConfig?.name || "app"`) stayed a local of the module's initializer, so reads from other modules and functions failed with "a binding form with no lowering". Such initializers now register the binding in checked-dynamic module storage, like the untyped initializers they combine. pi-coding-agent's config.js declares APP_NAME, APP_TITLE and isBundledNode this way. Refs #1777. --- .../src/frontend/lowering/lower-modules.ts | 17 ++++++++++------- .../main.ts.json | 6 ++++++ .../main.ts | 8 ++++++++ .../node_modules/app-config/dist/config.js | 18 ++++++++++++++++++ .../node_modules/app-config/dist/index.d.ts | 2 ++ .../node_modules/app-config/dist/index.js | 1 + .../node_modules/app-config/dist/usage.js | 12 ++++++++++++ .../node_modules/app-config/package.json | 1 + .../main.ts.json | 1 + 9 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json create mode 100644 tests/corpus/8215-js-any-logical-module-bindings/main.ts create mode 100644 tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js create mode 100644 tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts create mode 100644 tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js create mode 100644 tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js create mode 100644 tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json create mode 100644 tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 75f18fe37..19f7243ed 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1183,19 +1183,22 @@ function jsDynHoldableInitializer(lowerer: Lowerer, init: ts.Expression | undefi } if (e.kind === ts.SyntaxKind.NullKeyword) return true; if (ts.isIdentifier(e) && e.text === "undefined") return true; - if (ts.isConditionalExpression(e)) { - return ( - jsDynHoldableInitializer(lowerer, e.whenTrue) && - jsDynHoldableInitializer(lowerer, e.whenFalse) - ); - } + // The arms of a conditional or logical expression: checked values, or + // strings, numbers and booleans, which box into the checked-dynamic + // result (`const APP_NAME = config.name || "pi"`). + const arm = (x: ts.Expression): boolean => { + if (jsDynHoldableInitializer(lowerer, x)) return true; + const kind = lowerer.mapTypeOf(lowerer.typeOf(x))?.kind; + return kind === "string" || kind === "f64" || kind === "bool"; + }; + if (ts.isConditionalExpression(e)) return arm(e.whenTrue) && arm(e.whenFalse); if ( ts.isBinaryExpression(e) && (e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || e.operatorToken.kind === ts.SyntaxKind.BarBarToken || e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) ) { - return jsDynHoldableInitializer(lowerer, e.left) && jsDynHoldableInitializer(lowerer, e.right); + return arm(e.left) && arm(e.right); } // A member of a checked module value is itself checked, including a // callable whose inferred overloads cannot describe one native ABI. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json new file mode 100644 index 000000000..0ae9f6f2b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8215-js-any-logical-module-bindings/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8215-js-any-logical-module-bindings/main.ts b/tests/corpus/8215-js-any-logical-module-bindings/main.ts new file mode 100644 index 000000000..3a1d5eb17 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/main.ts @@ -0,0 +1,8 @@ +// Module bindings of a JavaScript package whose initializers combine an +// untyped value with a string, number or boolean through `||`, `&&`, `??` +// or `?:` (pi-coding-agent's config.js): other modules and functions read +// the values as in Node. +import { banner, usage } from "app-config"; + +console.log(banner); +console.log(usage("check")); diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js new file mode 100644 index 000000000..faecb8b2b --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +let pkg = {}; +try { + pkg = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf-8")); +} +catch (e) { + if (e.code !== "ENOENT") + throw e; +} +const configName = pkg.appConfig?.name; +export const APP_NAME = configName || "app"; +export const APP_TITLE = configName ? APP_NAME : "App"; +export const CONFIG_DIR = pkg.appConfig?.configDir || ".config"; +export const VERSION = pkg.version ?? "0.0.0"; +export const RETRIES = pkg.appConfig?.retries ?? 3; +export const isBundled = typeof APP_BUNDLED !== "undefined" && APP_BUNDLED; diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts new file mode 100644 index 000000000..4c4706926 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare const banner: string; +export declare function usage(kind: "check" | "list"): string; diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js new file mode 100644 index 000000000..251303802 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js @@ -0,0 +1 @@ +export { banner, usage } from "./usage.js"; diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js new file mode 100644 index 000000000..736cef3c0 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js @@ -0,0 +1,12 @@ +import { APP_NAME, APP_TITLE, CONFIG_DIR, RETRIES, VERSION, isBundled } from "./config.js"; + +const USAGE = { + check: `${APP_NAME} check --dir ${CONFIG_DIR}`, + list: `${APP_NAME} list`, +}; + +export const banner = `${APP_TITLE} ${VERSION} (bundled: ${isBundled}, retries: ${RETRIES})`; + +export function usage(kind) { + return `${USAGE[kind]} [${APP_NAME.toUpperCase()}_HOME=${CONFIG_DIR}] ${typeof isBundled} ${RETRIES + 1}`; +} diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json new file mode 100644 index 000000000..918204988 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json @@ -0,0 +1 @@ +{ "name": "app-config", "version": "2.1.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "appConfig": { "configDir": ".app" } } diff --git a/tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json b/tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } From 20339d9c277e1ffaf3be83bee35da5308fc4186b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 01:13:00 -0500 Subject: [PATCH 6/9] Process non-ASCII URL hosts with UTS #46 as Node does --- docs/content/limitations/builtin-apis/url.mdx | 3 +- .../compiler/src/backend/llvm/emit-module.ts | 4 ++ .../compiler/src/backend/vendor-archives.ts | 14 ++++- packages/compiler/src/ir/runtime-features.ts | 18 ++++-- .../tests/corpus/8105-url-idna-hosts.js.json | 6 ++ .../runtime-pack-common/scripts/build.mjs | 4 ++ packages/runtime/src/scr_idna.c | 11 ++-- packages/runtime/src/scr_url.c | 45 +++++++++----- packages/runtime/src/scr_url.h | 4 ++ tests/corpus/8105-url-idna-hosts.js | 58 +++++++++++++++++++ 10 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json create mode 100644 tests/corpus/8105-url-idna-hosts.js diff --git a/docs/content/limitations/builtin-apis/url.mdx b/docs/content/limitations/builtin-apis/url.mdx index c9eee85b3..dddf2a8f7 100644 --- a/docs/content/limitations/builtin-apis/url.mdx +++ b/docs/content/limitations/builtin-apis/url.mdx @@ -1,5 +1,4 @@ -- **URL:** Hosts of special URLs that hold non-ASCII text, or percent-escapes that decode to it, throw `ERR_INVALID_URL`, and `xn--` labels are kept without Punycode validation. - Absolute inputs and relative inputs with runtime string or URL bases, including optional bases, compile. +- **URL:** Absolute inputs and relative inputs with runtime string or URL bases, including optional bases, compile. `URL.canParse`, `URL.parse`, `toString()`, and `toJSON()` compile; the factories use the same parser as construction, returning `false` or `null` for rejected inputs while preserving conversion errors. `href`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash` support assignment in native code. Invalid component writes are ignored except for invalid `href` values, which throw; credentials are percent-encoded and ports accept valid leading digits. diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 5aa18bf9f..7f4ac73b6 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -344,6 +344,8 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // Fetch-referencing programs register native fetch before main. // Runtime selection compiles scr_fetch.c on the same predicate. const usesFetch = runtimeFeatures.fetch; + // URL programs give the host parser the IDNA unit's ToASCII. + const usesUrlIdna = runtimeFeatures.urlIdna; const snapshotsTlsCa = runtimeFeatures.tls || runtimeFeatures.tlsCa; // The process verdict has the same precedence as the C reference // emitter: node:test owns the final status when present; otherwise an @@ -396,6 +398,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { if (usesHttp2) emitter.declare(`declare void @scr_http2_dyn_install()`); if (usesHttp) emitter.declare(`declare void @scr_http_dyn_install()`); if (usesFetch) emitter.declare(`declare void @scr_fetch_install()`); + if (usesUrlIdna) emitter.declare(`declare void @scr_url_idna_install()`); if (usesNodeTest) { emitter.declare(`declare i32 @scr_test_exit_code()`); @@ -1027,6 +1030,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { ...(emitter.ffiHasForeignCallback ? [` call void @scr_ffi_install()`] : []), ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), ...(usesFetch ? [` call void @scr_fetch_install()`] : []), + ...(usesUrlIdna ? [` call void @scr_url_idna_install()`] : []), ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), ...(usesNetLookup ? [` call void @scr_net_lookup_install()`] : []), ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), diff --git a/packages/compiler/src/backend/vendor-archives.ts b/packages/compiler/src/backend/vendor-archives.ts index dbcaf2d36..ef63aa4e4 100644 --- a/packages/compiler/src/backend/vendor-archives.ts +++ b/packages/compiler/src/backend/vendor-archives.ts @@ -14,6 +14,10 @@ import type { CcDriver } from "./native/driver.js"; const execFileAsync = promisify(execFile); +/** The section flags the libregexp/libunicode objects build with (the + * runtime pack's executable_section_elimination flags). */ +const LRE_SECTION_FLAGS = ["-ffunction-sections", "-fdata-sections"]; + import { QJS_COMMIT, MBEDTLS_VERSION, @@ -191,13 +195,13 @@ export function createVendorArchives(context: VendorArchiveContext) { (sanitize ? "asan" : "plain") + (driver.argv.length === 1 && driver.argv[0] === "clang" ? "" : "-zigcc") + `-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`; - const cacheDir = join(cacheRoot, `${QJS_COMMIT.slice(0, 12)}-lre-${flavor}`); + const cacheDir = join(cacheRoot, `${QJS_COMMIT.slice(0, 12)}-lre-sections-${flavor}`); return LRE_SOURCES.map((f) => join(cacheDir, f.replace(/\.c$/, ".o"))); } /** The libregexp objects for one flavor, compiled lazily on the first * regex-using build (~1s) and cached under - * /vendor/-lre---/*.o — with the same atomic-rename + * /vendor/-lre-sections---/*.o — with the same atomic-rename * publish (parallel first builds race safely; losers discard their work). * Plain is -Os to keep regex-using binaries small; asan matches the final link so * the sanitized lane instruments the matcher too. Cross targets get their @@ -228,6 +232,10 @@ export function createVendorArchives(context: VendorArchiveContext) { vendor: vendorEngineDir(), sources: LRE_SOURCES, objects: lreObjectPaths(sanitize, driver, buildIdentity, cacheRoot), + // Section elimination keeps only the libunicode tables a program + // reaches: the URL host parser's NFC links the normalization tables + // without the regex property ones. + flags: LRE_SECTION_FLAGS, }); } @@ -296,6 +304,7 @@ export function createVendorArchives(context: VendorArchiveContext) { vendor: string; sources: readonly string[]; objects: string[]; + flags?: readonly string[]; }): Promise { const { sanitize, driver, cacheRoot, flavor, name, vendor, sources, objects } = options; if ((await Promise.all(objects.map(validVendorArtifact))).every(Boolean)) return objects; @@ -312,6 +321,7 @@ export function createVendorArchives(context: VendorArchiveContext) { "-std=c11", ...driver.targetArgs, ...(sanitize ? ["-O1", "-fsanitize=address"] : ["-Os"]), + ...(options.flags ?? []), "-I", vendor, "-c", diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 118f0ef38..85b21af7b 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -715,6 +715,10 @@ interface RuntimeFeatureRule { /** Gives every rule in the table one type. */ const rule = (r: RuntimeFeatureRule): RuntimeFeatureRule => r; +/* The node:url and WHATWG URL entry points: every URL value comes from + * one, and parsing one can reach the UTS #46 host processing. */ +const URL_PREFIXES: readonly string[] = ["url."]; + /* The HTTP, HTTPS, HTTP/2 and TLS surfaces, which link the net unit. */ const NET_PREFIXES: readonly string[] = ["net.", "http.", "tls.", "https.", "http2."]; @@ -1063,9 +1067,10 @@ const RUNTIME_FEATURE_RULES = { /** scr_url_legacy.c parses and stringifies queries. */ qs: rule({ fns: [...QS_LIB_FNS, ...LEGACY_URL_LIB_FNS] }), /** scr_url_legacy.c lowercases and normalizes hostnames with the regex - * unit's libunicode, NODE_DEBUG matching in scr_util_callable.c and - * readline's terminal unit use the regex unit, and locale case mapping - * runs its default conversion. */ + * unit's libunicode, the WHATWG URL host parser runs UTS #46 through + * scr_idna.c (which links with the regex unit), NODE_DEBUG matching in + * scr_util_callable.c and readline's terminal unit use the regex unit, + * and locale case mapping runs its default conversion. */ regex: rule({ fns: [ ...REGEX_LIB_FNS, @@ -1074,13 +1079,17 @@ const RUNTIME_FEATURE_RULES = { "rl.terminal", "str.localeCase", ], + prefixes: URL_PREFIXES, expr: (node, _tables) => node.kind === "regexLit" || node.kind === "regexIntrinsic" || (node.kind === "strIntrinsic" && CASE_MAPPING_STR_METHODS.has(node.method)), - types: ["regex"], + types: ["regex", "url"], }), searchParams: rule({ fns: ["url.searchParams"], prefixes: ["sp."], types: ["searchParams"] }), + /** URL programs install the IDNA unit's ToASCII for the WHATWG host + * parser (scr_url_idna_install). */ + urlIdna: rule({ prefixes: URL_PREFIXES, types: ["url"] }), /** stdin setEncoding / string data listeners / stdio 'resize' events. A * typed string 'data' listener takes setEncoding-decoded chunks; the * process stream object's setEncoding and 'resize' members are reached @@ -1177,6 +1186,7 @@ function noRuntimeFeatures(): RuntimeFeatures { qs: false, regex: false, searchParams: false, + urlIdna: false, stdioExtended: false, stream: false, streamDyn: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json new file mode 100644 index 000000000..50ed1b401 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8105-url-idna-hosts.js" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index d4794b7d1..479d8aad4 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -386,6 +386,10 @@ async function build() { ? [ await archive("libregexp", lreSources, quickjs, [ ...vendorTarget, + // Section elimination keeps only the libunicode tables a + // program reaches: the URL host parser's NFC links the + // normalization tables without the regex property ones. + ...matrix.executable_section_elimination.compile_flags, "-std=c11", "-Os", "-I", diff --git a/packages/runtime/src/scr_idna.c b/packages/runtime/src/scr_idna.c index 34c9e3882..7507e5caa 100644 --- a/packages/runtime/src/scr_idna.c +++ b/packages/runtime/src/scr_idna.c @@ -1,8 +1,8 @@ /* UTS #46 domain processing for node:url — the conversions Node applies to - * url.parse() hostnames (ToASCII), url.domainToASCII()/domainToUnicode() - * and url.format()'s unicode option: nontransitional mapping, NFC, label - * validity (leading combining marks, ContextJ, and the RFC 5893 bidi rule), - * and Punycode. It matches Node 24's implementation, including its + * WHATWG URL hosts and url.parse() hostnames (ToASCII), + * url.domainToASCII()/domainToUnicode() and url.format()'s unicode option: + * nontransitional mapping, NFC, label validity (leading combining marks, + * ContextJ, and the RFC 5893 bidi rule), and Punycode. It matches Node 24's implementation, including its * validity-data snapshot and the shortcuts its label checks take; the * differential corpus and test/url-legacy-cases.txt (generated from Node) * are the oracle. @@ -511,3 +511,6 @@ ScrStr *scr_idna_to_unicode(const char *p, size_t n) { } return scr_jb_finish(&out); } + +/* The URL host parser's UTS #46 ToASCII (scr_url.c). */ +void scr_url_idna_install(void) { scr_url_host_to_ascii = &scr_idna_to_ascii; } diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 09ccb87fe..a61286c63 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -21,10 +21,8 @@ * case preserved, C0-encoded), a single "/" roots a normalized path, * anything else is an OPAQUE path with the C0 control set encoded * (data:text/plain,hi there) - * - * DOCUMENTED DIVERGENCE: a special host that holds non-ASCII text after - * percent-decoding is rejected with "Invalid URL" (no UTS #46 processing), - * and "xn--" labels are kept without Punycode validation. + * - UTS #46 processing of non-ASCII hosts through the IDNA unit + * (scr_url_idna_install) * * Failures THROW catchable TypeErrors through the exception cell with * Node's messages; callers are compiler-emitted pending checks. */ @@ -430,16 +428,21 @@ static int hex_val(char c) { return -1; } +/* UTS #46 ToASCII (scr_idna.c), which programs that use URLs install at + * startup (scr_url_idna_install). NULL refuses hosts that hold non-ASCII + * text. */ +ScrStr *(*scr_url_host_to_ascii)(const char *p, size_t n) = NULL; + /* The URL Standard's host parser for the constructor and the setters, * with canonical IPv6 serialization: * - a non-special (opaque) host refuses forbidden host code points and * percent-encodes the C0 control set; - * - a special host is percent-decoded, lowercased, refused when it holds a - * forbidden domain code point, and serialized as dotted decimal when it - * ends in a number (or refused when that number is invalid). - * Divergence: a special host that decodes to non-ASCII needs UTS #46 - * processing, which this unit does not link, so it is refused; "xn--" - * labels are kept without Punycode validation. */ + * - a special host is percent-decoded and lowercased; when that leaves + * non-ASCII text it goes through UTS #46 ToASCII (as Node's host parser + * does, an ASCII host, "xn--" labels included, is only lowercased). The + * result is refused when it holds a forbidden domain code point, and + * serialized as dotted decimal when it ends in a number (or refused when + * that number is invalid). */ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_file, ScrStr **host) { bool ipv6 = host_len > 0 && hp[0] == '['; @@ -467,6 +470,7 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi *host = ub_take(&hb); return true; } + bool non_ascii = false; for (size_t i = 0; i < host_len; i++) { unsigned char c = (unsigned char)hp[i]; int hi, lo; @@ -475,12 +479,27 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi c = (unsigned char)(hi * 16 + lo); i += 2; } - if (url_host_byte_class[c] & URL_FORBIDDEN_DOMAIN) { + if (c >= 0x80) non_ascii = true; + if (c >= 'A' && c <= 'Z') c = (unsigned char)(c - 'A' + 'a'); + ub_push(&hb, (char)c); + } + if (non_ascii) { + /* UTS #46 ToASCII, or refused without the IDNA unit. */ + ScrStr *ascii = scr_url_host_to_ascii ? scr_url_host_to_ascii(hb.data, hb.len) : NULL; + free(hb.data); + if (!ascii || ascii->len == 0) { + scr_str_release(ascii); + return false; + } + ub_init(&hb); + ub_append(&hb, ascii->data, ascii->len); + scr_str_release(ascii); + } + for (size_t i = 0; i < hb.len; i++) { + if (url_host_byte_class[(unsigned char)hb.data[i]] & URL_FORBIDDEN_DOMAIN) { free(hb.data); return false; } - if (c >= 'A' && c <= 'Z') c = (unsigned char)(c - 'A' + 'a'); - ub_push(&hb, (char)c); } if (url_ends_in_number(hb.data, hb.len)) { UrlBuf ip; diff --git a/packages/runtime/src/scr_url.h b/packages/runtime/src/scr_url.h index c7c9c63c8..13a7d093b 100644 --- a/packages/runtime/src/scr_url.h +++ b/packages/runtime/src/scr_url.h @@ -46,6 +46,10 @@ void scr_url_set_checked(ScrUrl *u, ScrStr *field, const ScrDyn *value); /* borr * null and has no lowering for an object that is not a URL. Borrowed; +1, * or NULL with the error pending. */ ScrDyn *scr_url_to_http_options(ScrUrl *u); +/* UTS #46 ToASCII for the host parser: NULL until scr_url_idna_install + * (scr_idna.c, emitted at startup by programs that use URLs) sets it. */ +extern ScrStr *(*scr_url_host_to_ascii)(const char *p, size_t n); +void scr_url_idna_install(void); ScrDyn *scr_url_to_http_options_dyn(const ScrDyn *value); /* node:url's legacy surface (scr_url_legacy.c — linked with the regex, * querystring and dyn-async units). url.parse() answers a checked-dynamic diff --git a/tests/corpus/8105-url-idna-hosts.js b/tests/corpus/8105-url-idna-hosts.js new file mode 100644 index 000000000..329566013 --- /dev/null +++ b/tests/corpus/8105-url-idna-hosts.js @@ -0,0 +1,58 @@ +// WHATWG URL hosts that hold non-ASCII text, directly or after +// percent-decoding, go through UTS #46 ToASCII as the host parser in Node +// does: mapped and normalized labels become Punycode, and disallowed code +// points, invalid joiners and bidi violations make the URL invalid. Cases +// from the WPT url test data, plus setters, pathToFileURL UNC hosts and +// URL.canParse. +import url from "node:url"; + +const parses = [ + ["http://é@é", null], + ["http://GOO\u200B\u2060\uFEFFgoo.com", "http://other.com/"], + ["http://www.foo。bar.com", "http://other.com/"], + ["http://Go.com", "http://other.com/"], + ["http://你好你好", "http://other.com/"], + ["https://faß.ExAmPlE/", null], + ["http://0Xc0.0250.01", "http://other.com/"], + ["ftp://%e2%98%83", null], + ["https://%e2%98%83", null], + ["file://a\u00ADb/p", null], + ["file://a%C2%ADb/p", null], + ["file://loC𝐀𝐋𝐇𝐨𝐬𝐭/usr/bin", null], + ["https://a%C2%ADb/", null], + ["http://münchen.de/straße", null], + ["https://日本語.jp/", null], + ["http://例え.テスト/", null], + ["http://xn--mnchen-3ya.de/", null], + ["http://a\u200Db.com/", null], + ["http://אa.com/", null], + ["http://ab�c/", null], + ["ws://ÉXAMPLE.org:8080/x", null], +]; +function describe(value) { + return [value.href, value.host, value.hostname, value.origin].join(" | "); +} +for (const [input, base] of parses) { + let line; + try { + line = describe(base === null ? new URL(input) : new URL(input, base)); + } catch (e) { + line = `throws ${e.code}`; + } + console.log(JSON.stringify(input), "=>", line, URL.canParse(input, base ?? undefined)); +} +const writes = [ + ["host", "https://x/", "ß"], + ["host", "https://example.com/", "a%C2%ADb"], + ["hostname", "https://example.com/", "a%C2%ADb"], + ["hostname", "https://example.com/", "BÜCHER.example"], +]; +for (const [name, href, value] of writes) { + const target = new URL(href); + if (name === "host") target.host = value; + else target.hostname = value; + console.log(name, JSON.stringify(value), "=>", describe(target)); +} +for (const path of ["\\\\münchen\\share\\f", "\\\\BÜCHER\\s\\f"]) { + console.log(JSON.stringify(path), url.pathToFileURL(path, { windows: true }).href); +} From 937db546561ca427cc85b027f3033239d8164084 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 01:42:23 -0500 Subject: [PATCH 7/9] Raise the capped Linux test scope to 15 GB of RAM The sanitized self-hosting native-frontend build needs about 15 GB by itself: its clang compile of the whole frontend under ASan peaks at 10.5 GB beside a 3.8 GB compile in Node. In the 14 GB scope, with the shard's other work running beside it, the kernel killed that clang intermittently, so SC5004 reported clang's warnings and no error (SAN 16/20 and 19/20). The scope now caps RAM at 15 GB, still below the runner's 16 GB, and may still swap past it. --- .github/workflows/ci.yml | 12 +++++++----- tests/harness/ci-test-plan.test.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d3ecbf18..edb2b5731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,19 +217,21 @@ jobs: # worker budget covers both processes; file and case shard axes stay # separate so every fixture still runs exactly once in each lane. # The tests run in a transient scope capped below the runner's 16 GB, so - # the runner itself keeps memory: past 14 GB of RAM the scope swaps, as - # the whole runner did before (a sanitized shard peaks near 16 GB), and - # when RAM and swap run out the kernel kills the scope's largest process. + # the runner itself keeps memory: past 15 GB of RAM the scope swaps, as + # the whole runner did before, and when RAM and swap run out the kernel + # kills the scope's largest process. A sanitized shard peaks near 16 GB, + # and the sanitized self-hosting native-frontend build alone needs 15 GB + # (its clang compile of the frontend takes 10.5 GB). # OOMPolicy=continue leaves the rest of the scope running, so vitest # reports the killed worker and the job log names the test. Node sizes # its default heap from the memory it may use, which the scope lowers - # to about 3.5 GB; the workers keep the 4 GB heap they had on the bare + # below 4 GB; the workers keep the 4 GB heap they had on the bare # runner. - name: Tests (corpus and integration suites) run: | ulimit -c 0 if [ -d /run/systemd/system ]; then - exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue \ + exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=15G -p OOMPolicy=continue \ --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" NODE_OPTIONS="--max-old-space-size=4096" node scripts/ci-test.mjs fi node scripts/ci-test.mjs diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index 6cee99370..29268c841 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -298,7 +298,7 @@ test("pull requests run the full suite on Linux and the darwin suite on macOS", // runner, and directly elsewhere. const linuxRun = linux.steps.at(-1)!.run as string; expect(linuxRun.startsWith("ulimit -c 0\n")).toBe(true); - expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue"); + expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=15G -p OOMPolicy=continue"); expect(linuxRun).toMatch( /-- env PATH="\$PATH" HOME="\$HOME" NODE_OPTIONS="--max-old-space-size=4096" node scripts\/ci-test\.mjs\n/, ); From c652e418f64ceffdd7782f20406ec4c147d26153 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 04:10:52 -0500 Subject: [PATCH 8/9] Read the discriminant again after an arm's check fails An arm's field check can run a getter that changes the discriminant. The checked cast now reads the discriminant again after each failed arm; when its case changed, the tests continue arm by arm after the failed arm, so the cast picks the arm the arm-by-arm tests pick. --- packages/compiler/src/backend/llvm/dyn.ts | 79 ++++++++++++------- .../6306-discriminant-getter-dispatch.ts.json | 6 ++ .../6306-discriminant-getter-dispatch.ts | 55 +++++++++++++ .../6306-discriminant-getter-dispatch.ts.json | 1 + tests/harness/dyncheck.test.ts | 41 ++++++++++ 5 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json create mode 100644 tests/corpus/6306-discriminant-getter-dispatch.ts create mode 100644 tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 3d7533f22..218d316dc 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -440,8 +440,12 @@ export class LlDyn { * discriminant is read once (scr_dyn_discriminant_case, against the * constant `table`), and only the arms whose literal it holds and the arms * without a literal are tested, in `order`: every other arm's literal test - * fails. A value the runtime cannot classify that way (a typed capsule, an - * accessor, an inherited discriminant) takes the tests arm by arm. */ + * fails. An arm's failed check can run a getter that changes the + * discriminant, so it is read again after each one; when its case + * changed, the tests continue arm by arm after the failed arm, as they + * would have run from the first arm. A value the runtime cannot classify + * that way (a typed capsule, an accessor, an inherited discriminant) takes + * the tests arm by arm. */ private unionArmsByDiscriminant( B: BlockBuilder, def: IrUnionDef, @@ -455,47 +459,66 @@ export class LlDyn { const discriminant = def.discriminant!; const literalsOf = (tag: number): readonly (string | number | boolean)[] | null => discriminant.cases.find((candidate) => candidate.tag === tag)?.values ?? null; - const tryArms = (tags: readonly number[], literal: boolean): void => { - if (tags.length === 0) { - B.br(none); - return; - } - tags.forEach((tag, index) => { - const next = index === tags.length - 1 ? none : B.newLabel("du.c.n"); - if (literal) this.unionArmMatch(B, def, tag, hits[tag]!, next, preserveRefs); - else this.unionArmStructure(B, def, tag, hits[tag]!, next, preserveRefs); - if (next !== none) B.startBlock(next); - }); - }; - const armsFor = (value: string | null): number[] => - order.filter((tag) => { - const literals = literalsOf(tag); - return literals === null || (value !== null && literals.includes(value)); - }); const host = this.host; this.defs.push( `${table} = private unnamed_addr constant [${values.length} x ptr] [${values.map((value) => `ptr ${host.internLiteral(value)}`).join(", ")}]`, ``, ); host.declare(`declare i32 @scr_dyn_discriminant_case(ptr, ptr, ptr, ${host.sizeType})`); - const index = B.tmp(); - B.line( - `${index} = call i32 @scr_dyn_discriminant_case(ptr %d, ptr ${host.internLiteral(discriminant.field)}, ptr ${table}, ${host.sizeType} ${values.length})`, - ); + const classify = (): string => { + const index = B.tmp(); + B.line( + `${index} = call i32 @scr_dyn_discriminant_case(ptr %d, ptr ${host.internLiteral(discriminant.field)}, ptr ${table}, ${host.sizeType} ${values.length})`, + ); + return index; + }; + // The arm-by-arm tests, entered at any arm (`from[i]` tests order[i] + // and the arms after it). const tests = B.newLabel("du.tests"); + const from = order.map((_, i) => (i === 0 ? tests : B.newLabel("du.t"))); + const after = (position: number): string => + position + 1 < order.length ? from[position + 1]! : none; + // Tests the arms a value in case `k` (-1: no literal) can match. + const tryCase = (k: number): void => { + const value = k < 0 ? null : values[k]!; + const positions = order + .map((_, position) => position) + .filter((position) => { + const literals = literalsOf(order[position]!); + return literals === null || (value !== null && literals.includes(value)); + }); + if (positions.length === 0) { + B.br(none); + return; + } + positions.forEach((position, index) => { + const tag = order[position]!; + const failed = B.newLabel("du.c.f"); + this.unionArmStructure(B, def, tag, hits[tag]!, failed, preserveRefs); + B.startBlock(failed); + const next = index === positions.length - 1 ? none : B.newLabel("du.c.n"); + const same = B.tmp(); + B.line(`${same} = icmp eq i32 ${classify()}, ${k}`); + B.condBr(same, next, after(position)); + if (next !== none) B.startBlock(next); + }); + }; + const index = classify(); const unmatched = B.newLabel("du.none"); const cases = values.map(() => B.newLabel("du.case")); B.terminate( `switch i32 ${index}, label %${tests} [ i32 -1, label %${unmatched} ${cases.map((label, k) => `i32 ${k}, label %${label}`).join(" ")} ]`, ); - values.forEach((value, k) => { + values.forEach((_, k) => { B.startBlock(cases[k]!); - tryArms(armsFor(value), false); + tryCase(k); }); B.startBlock(unmatched); - tryArms(armsFor(null), false); - B.startBlock(tests); - tryArms(order, true); + tryCase(-1); + order.forEach((tag, position) => { + B.startBlock(from[position]!); + this.unionArmMatch(B, def, tag, hits[tag]!, after(position), preserveRefs); + }); } /** Branch to a variant only after its literal discriminator and complete diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json new file mode 100644 index 000000000..ba7cba509 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6306-discriminant-getter-dispatch.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6306-discriminant-getter-dispatch.ts b/tests/corpus/6306-discriminant-getter-dispatch.ts new file mode 100644 index 000000000..4f0a1244f --- /dev/null +++ b/tests/corpus/6306-discriminant-getter-dispatch.ts @@ -0,0 +1,55 @@ +// A checked cast into a union with many string discriminant literals reads +// the discriminant once, but a getter that runs while an arm's fields are +// checked can change it. The cast then continues with the arms after the +// one that failed, testing each arm's literal again, and matches the arm +// the new discriminant names. Each getter changes the discriminant on its +// first call only; the program reads the getter once itself, so Node (where +// the cast runs no code) reaches the same state. Valid casts only (see +// 1002-json-parse-cast.ts). + +type Shape = + | { kind: "circle"; radius: number } + | { kind: "square"; side: number } + | { kind: "rect"; height: number; width: number } + | { kind: "tri"; a: number; b: number; c: number }; + +function area(shape: Shape): number { + switch (shape.kind) { + case "circle": + return Math.round(Math.PI * shape.radius * shape.radius); + case "square": + return shape.side * shape.side; + case "rect": + return shape.width * shape.height; + case "tri": { + const s = (shape.a + shape.b + shape.c) / 2; + return Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c)); + } + } +} + +// The circle arm reads `radius`, whose getter turns the value into a square. +let squareReads = 0; +const square: any = { + kind: "circle", + get radius() { + squareReads++; + if (squareReads === 1) square.kind = "square"; + return "none"; + }, + side: 3, +}; +const asSquare = square as Shape; +void square.radius; +console.log(square.kind, asSquare.kind, area(asSquare)); + +// Values without getters take the arm their discriminant names. +const plain: any[] = [ + { kind: "rect", width: 2, height: 5 }, + { kind: "tri", a: 3, b: 4, c: 5 }, + { kind: "circle", radius: 2 }, +]; +for (const value of plain) { + const shape = value as Shape; + console.log(shape.kind, area(shape)); +} diff --git a/tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json b/tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json new file mode 100644 index 000000000..2c49e2f02 --- /dev/null +++ b/tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 4900 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 5559be970..1ceabce53 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -444,6 +444,47 @@ for (const input of inputs) { ); }); + test("a getter that changes the discriminant during an arm's check continues with the later arms", async () => { + // Each getter sets `kind` and fails its arm's check: to a later arm's + // literal (that arm matches), to an earlier arm's literal (arm-by-arm + // tests do not go back), and to a literal no arm holds. + const r = await compileAndRun( + "union-discriminant-getter", + `type Shape = + | { kind: "circle"; radius: number } + | { kind: "square"; side: number } + | { kind: "rect"; height: number; width: number } + | { kind: "tri"; a: number; b: number; c: number }; +const flip = (value: any, to: string): string => { + value.kind = to; + return "none"; +}; +const later: any = { kind: "circle", get radius() { return flip(later, "square"); }, side: 3 }; +const earlier: any = { kind: "square", radius: 2, get side() { return flip(earlier, "circle"); } }; +const unknown: any = { kind: "square", get side() { return flip(unknown, "hexagon"); } }; +const inputs = [later, earlier, unknown]; +for (const input of inputs) { + try { + const shape = input as Shape; + console.log("matched", shape.kind); + } catch (error) { + console.log((error as Error).message); + } +} +`, + ); + const any = "object | object | object | object"; + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "matched square", + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + "", + ].join("\n"), + ); + }); + test("nested paths point at the offending element", async () => { const r = await compileAndRun( "nested-path", From 8a02468073255db4bad38f098f7206052d99a408 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 05:57:26 -0500 Subject: [PATCH 9/9] Take number and string boxes out of the candidate buffers before pooling them scr_dyn_release returned the last reference of a number or string box to the free list without scr_cyc_dead, because the release paths never buffer those boxes. A collector pass can: capture boxes trace their payload, and scr_xg_release, which settles the cross-generation edges a restricted pass skipped, calls scr_cyc_on_release on the target whatever its kind. The pooled block then stayed in its candidate buffer, and the next box that reused it carried the stale buffer position. Its death swapped another object into that slot, which corrupted the buffers and crashed a later scr_cyc_on_dead. The fast path now takes a buffered box out of its buffer first. The sanitized runtime does not pool boxes, so only plain builds were affected. --- packages/runtime/src/scr_json.c | 8 ++++++-- packages/runtime/src/scr_json.test.c | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index c2396212d..eb03ab6cc 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1286,10 +1286,14 @@ void scr_dyn_release(ScrDyn *d) { return; } #ifndef SCR_RC_AUDIT - /* Number and string boxes own at most one string, are never cycle - * candidates and cannot be weak keys: return them to the free list. */ + /* Number and string boxes own at most one string and cannot be weak + * keys: return them to the free list. Their releases never buffer them, + * but a collector pass can (a box that traces its payload reaches them, + * and scr_xg_release buffers what it settles), so a buffered box leaves + * its candidate buffer first. */ if ((d->kind == SCR_DYN_NUM || d->kind == SCR_DYN_STR) && !d->prototype && !d->symbol_properties && !d->symbol_keys && scr_dyn_free_count < SCR_DYN_FREE_MAX) { + scr_cyc_dead(d); if (d->kind == SCR_DYN_STR) scr_str_release(d->v.str); d->v.str = (ScrStr *)scr_dyn_free_misc; scr_dyn_free_misc = d; diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index a28759876..29ba4bfe3 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -391,6 +391,30 @@ static bool buffered_after_read(ScrDyn *d) { return buffered(d); } +/* A collector pass can buffer a number or string box that it reached + * through an owner that traces it: scr_xg_release, settling an edge a + * restricted pass skipped, calls scr_cyc_on_release on the target. The + * box's last release must take it out of its candidate buffer before its + * block returns to the free list, which hands it to the next box. */ +static void buffered_leaf_release(void) { + for (uint16_t gen = SCR_CYC_NURSERY; gen < SCR_CYC_NGENS; gen++) { + ScrStr *text = scr_str_new("buffered", 8); + ScrDyn *leaves[2] = {scr_dyn_new_str(text), scr_dyn_new_num(0.5)}; + scr_str_release(text); + for (int i = 0; i < 2; i++) { + scr_cyc_set_gen(scr_cyc_hdr(leaves[i]), gen); + scr_cyc_on_release(leaves[i]); + scr_dyn_release(leaves[i]); + } + ScrDyn *fresh[2] = {scr_dyn_new_num(1.5), scr_dyn_new_num(2.5)}; + assert(!buffered(fresh[0]) && !buffered(fresh[1])); + scr_collect_cycles(); + assert(fresh[0]->v.num == 1.5 && fresh[1]->v.num == 2.5); + scr_dyn_release(fresh[0]); + scr_dyn_release(fresh[1]); + } +} + static ScrDyn *parse_text(const char *text) { ScrStr *input = scr_str_new(text, strlen(text)); ScrDyn *parsed = scr_json_parse(input); @@ -823,6 +847,7 @@ int main(void) { shared_property_keys(); checked_leaf_cycles(); checked_number_storage(); + buffered_leaf_release(); json_intact_regions(); json_schema_parse(); json_schema_special_names();