From ec88372ebaea6001bd5a127e67ad3261ea21d14c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:55 -0700 Subject: [PATCH 001/193] Defer island modules whose recorded load is deterministic (NI-7b/7d/7e/7f) Node islands stack, part 9 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/lower-node-island.ts | 25 +- .../src/node-islands/effect-gate.test.ts | 5 +- .../compiler/src/node-islands/effect-gate.ts | 43 +- .../src/node-islands/probe-recorder.ts | 489 ++++++++++++++++++ .../compiler/src/node-islands/snapshot.ts | 311 +++++------ .../main.ts.json | 6 + .../main.ts.json | 6 + .../main.ts.json | 6 + .../main.ts.json | 6 + .../main.ts.json | 6 + packages/runtime/src/scr_node_island.c | 9 +- packages/runtime/src/scr_node_island.h | 2 + packages/runtime/src/scr_node_island_boot.c | 157 +++++- packages/runtime/src/scr_node_island_handle.c | 13 +- .../runtime/src/tools/scr_node_island_probe.c | 16 +- .../main.ts | 12 + .../node_modules/pkg-a/index.d.ts | 2 + .../node_modules/pkg-a/index.js | 8 + .../node_modules/pkg-a/package.json | 1 + .../node_modules/pkg-b/index.d.ts | 5 + .../node_modules/pkg-b/index.js | 14 + .../node_modules/pkg-b/package.json | 1 + .../package.json | 1 + .../6844-node-island-deferred-env/main.ts | 11 + .../node_modules/env-pkg/index.d.ts | 1 + .../node_modules/env-pkg/index.js | 7 + .../node_modules/env-pkg/package.json | 1 + .../package.json | 1 + .../6845-node-island-deferred-require/main.ts | 16 + .../node_modules/native-wrapper/index.d.ts | 2 + .../node_modules/native-wrapper/index.js | 14 + .../node_modules/native-wrapper/package.json | 1 + .../node_modules/timer-pkg/index.d.ts | 2 + .../node_modules/timer-pkg/index.js | 18 + .../node_modules/timer-pkg/package.json | 1 + .../package.json | 1 + .../main.ts | 14 + .../node_modules/data-pkg/data.txt | 1 + .../node_modules/data-pkg/index.d.ts | 1 + .../node_modules/data-pkg/index.js | 14 + .../node_modules/data-pkg/package.json | 1 + .../package.json | 1 + .../legacy.cjs | 4 + .../6847-node-island-punycode-warning/main.ts | 18 + .../node_modules/puny-pkg/index.d.ts | 1 + .../node_modules/puny-pkg/index.js | 8 + .../node_modules/puny-pkg/package.json | 1 + .../node_modules/warn-pkg/index.d.ts | 1 + .../node_modules/warn-pkg/index.js | 6 + .../node_modules/warn-pkg/package.json | 1 + .../package.json | 1 + tests/harness/differential.test.ts | 22 +- tests/harness/llvm-differential.test.ts | 11 +- 53 files changed, 1136 insertions(+), 190 deletions(-) create mode 100644 packages/compiler/src/node-islands/probe-recorder.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6843-node-island-deferred-per-module/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6844-node-island-deferred-env/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6845-node-island-deferred-require/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6846-node-island-deferred-own-files/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6847-node-island-punycode-warning/main.ts.json create mode 100644 tests/corpus/6843-node-island-deferred-per-module/main.ts create mode 100644 tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.d.ts create mode 100644 tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.js create mode 100644 tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/package.json create mode 100644 tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.d.ts create mode 100644 tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.js create mode 100644 tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/package.json create mode 100644 tests/corpus/6843-node-island-deferred-per-module/package.json create mode 100644 tests/corpus/6844-node-island-deferred-env/main.ts create mode 100644 tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.d.ts create mode 100644 tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.js create mode 100644 tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/package.json create mode 100644 tests/corpus/6844-node-island-deferred-env/package.json create mode 100644 tests/corpus/6845-node-island-deferred-require/main.ts create mode 100644 tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.d.ts create mode 100644 tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.js create mode 100644 tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/package.json create mode 100644 tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.d.ts create mode 100644 tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.js create mode 100644 tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/package.json create mode 100644 tests/corpus/6845-node-island-deferred-require/package.json create mode 100644 tests/corpus/6846-node-island-deferred-own-files/main.ts create mode 100644 tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/data.txt create mode 100644 tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.d.ts create mode 100644 tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.js create mode 100644 tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/package.json create mode 100644 tests/corpus/6846-node-island-deferred-own-files/package.json create mode 100644 tests/corpus/6847-node-island-punycode-warning/legacy.cjs create mode 100644 tests/corpus/6847-node-island-punycode-warning/main.ts create mode 100644 tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.d.ts create mode 100644 tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.js create mode 100644 tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/package.json create mode 100644 tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.d.ts create mode 100644 tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.js create mode 100644 tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/package.json create mode 100644 tests/corpus/6847-node-island-punycode-warning/package.json diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 40fb46f8f..6e80d991a 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -322,6 +322,17 @@ function deferIslandModule(path: string, loc: SrcLoc): IrExpr { }; } +/** Whether `node` runs while its module evaluates: no enclosing function, + * method, accessor, constructor or class member initializer. */ +function runsAtModuleEvaluation(node: ts.Node): boolean { + for (let n = node.parent; n !== undefined; n = n.parent) { + if (ts.isSourceFile(n)) return true; + if (ts.isFunctionLike(n) || ts.isClassStaticBlockDeclaration(n) || ts.isPropertyDeclaration(n)) + return false; + } + return false; +} + /** A static import (or re-export) of an island package: loads the module * in the importer's %init prelude. Returns false when `spec` is not an * island module. */ @@ -1512,7 +1523,14 @@ function lowerIslandLoadCall( loc, }; } - return fromIsland(lowerer, loadIslandModule(path, loc), expr); + // A require() the module's own evaluation runs (not inside a function or + // class body) loads the island exactly when a static import would: it is + // deferred the same way (scope-B NI-7d), so a native package's top-level + // `const x = require("")` does not start Node at boot. + const load = runsAtModuleEvaluation(expr) + ? deferIslandModule(path, loc) + : loadIslandModule(path, loc); + return fromIsland(lowerer, load, expr); } /* ── native classes extending island classes (scope-B NI-5b) ─────────── @@ -1841,7 +1859,8 @@ function requireSpecOf(decl: ts.Node): string | null { /** Top-level `const x = require("pkg")` statements of island packages in a * compiled module (a native package requiring an island package): load the * island module in the module's %init prelude (shortcut: before the body, - * not at the statement). */ + * not at the statement), deferred exactly like a static import (scope-B + * NI-7d: the build decides from the probe, stubs stand in until first use). */ export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void { const islands = lowerer.nodeIslands; if (islands === null) return; @@ -1860,7 +1879,7 @@ export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void actions.push({ kind: "assign", localId: binding.global.id, - value: loadIslandModule(path, loc), + value: deferIslandModule(path, loc), loc, }); } diff --git a/packages/compiler/src/node-islands/effect-gate.test.ts b/packages/compiler/src/node-islands/effect-gate.test.ts index e2d753511..4f224cc57 100644 --- a/packages/compiler/src/node-islands/effect-gate.test.ts +++ b/packages/compiler/src/node-islands/effect-gate.test.ts @@ -37,6 +37,9 @@ exports.sep = path.sep; exports.join = join; exports.cache = new Map(); exports.mode = typeof window === "undefined" ? "node" : "browser"; +exports.size = LIMIT * 2 + VERSION.length; +exports.label = "v" + VERSION; +var __importDefault = (this && this.__importDefault) || function (m) { return m; }; `, "lib.js": `exports.x = 1;\n`, }); @@ -69,7 +72,7 @@ exports.mode = typeof window === "undefined" ? "node" : "browser"; ["an unknown global", `const w = window;`], ["a static block", `class A { static { console.log(1); } }`], ["a getter with effects", `Object.defineProperty(exports, "x", { get() { return 1; } });`], - ["a template substitution", "const s = `${{}}`;"], + ["an increment", "let i = 0; const j = i++;"], ])("refuses %s", (_what, text) => { expect(effectGate(pkg({ "index.js": text })).ok).toBe(false); }); diff --git a/packages/compiler/src/node-islands/effect-gate.ts b/packages/compiler/src/node-islands/effect-gate.ts index 74832c5d1..578cfffae 100644 --- a/packages/compiler/src/node-islands/effect-gate.ts +++ b/packages/compiler/src/node-islands/effect-gate.ts @@ -6,10 +6,14 @@ * files), and no throw. The gate is the conservative half of that proof: * it admits a module only when the top-level code of every file it loads * (the closure over literal require()/import specifiers) is built from the - * forms below, which cannot have effects whatever the environment. The - * build-time probe (snapshot.ts) then runs the admitted modules in libnode - * and confirms they load without throwing and without the effects it can - * observe. + * forms below, which cannot have effects whatever the environment. + * + * NI-7b: the gate is the static tier only. Whether a module defers is + * decided by the build-time probe's load recording (probe-recorder.ts): + * a load that reads no run-time input is deterministic, so one recorded + * run without effects proves deferral unobservable whether or not the + * gate admits the code (bundles such as esbuild output never pass the + * gate). The gate's verdict is reported with the decision. * * Admitted top-level statements (per file, CommonJS or ES module): * - directives ("use strict"), empty statements; @@ -36,9 +40,9 @@ * admitted class expressions; object and array literals of pure parts * (no spreads, no accessors); `require("")` of an admitted file or * an allowlisted builtin; member reads on required modules, allowlisted - * globals and same-file bindings; `typeof`, `void`, `!`; `===`, `!==`, - * `&&`, `||`, `??` and the conditional operator over pure operands; - * arithmetic between literals; `Symbol(…)`, `Symbol.for(…)`, + * globals and same-file bindings; unary and binary operators (no + * assignments or increments) and the conditional operator over pure + * operands; CommonJS's top-level `this`; `Symbol(…)`, `Symbol.for(…)`, * `Object.freeze(…)` and `Object.create(…)` over pure arguments; `new` of * Map, Set, WeakMap and WeakSet without arguments. * @@ -290,6 +294,7 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void return PURE_GLOBALS.has(e.text) || (!esm && (e.text === "module" || e.text === "exports")); } if (parser.isPropertyAccessExpression(e)) return readable(e.expression); + if (e.kind === SK.ThisKeyword) return !esm; if (parser.isCallExpression(e)) return isRequireCall(e); return false; }; @@ -322,13 +327,14 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void case SK.FunctionExpression: case SK.ArrowFunction: return; + case SK.ThisKeyword: + // CommonJS: `exports` (tsc's `this && this.__importDefault`). + if (esm) refuse(e, "`this` in an ES module"); + return; case SK.ClassExpression: return checkClass(e as ts.ClassExpression); case SK.TemplateExpression: - for (const span of (e as ts.TemplateExpression).templateSpans) { - // A substitution converts its value to a string: literals only. - if (!isLiteral(span.expression)) refuse(span.expression, "a template substitution"); - } + for (const span of (e as ts.TemplateExpression).templateSpans) pure(span.expression); return; case SK.ObjectLiteralExpression: for (const p of (e as ts.ObjectLiteralExpression).properties) { @@ -368,9 +374,9 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void return pure((e as ts.VoidExpression).expression); case SK.PrefixUnaryExpression: { const u = e as ts.PrefixUnaryExpression; - if (u.operator === SK.ExclamationToken) return pure(u.operand); - if (isLiteral(u.operand)) return; - return refuse(e, "a unary operator on a non-literal"); + if (u.operator === SK.PlusPlusToken || u.operator === SK.MinusMinusToken) + return refuse(e, "an increment"); + return pure(u.operand); } case SK.BinaryExpression: { const b = e as ts.BinaryExpression; @@ -387,8 +393,13 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void pure(b.right); return; } - if (isLiteral(b.left) && isLiteral(b.right) && op !== SK.EqualsToken) return; - return refuse(e, "a binary operator on non-literals"); + if (op >= SK.FirstAssignment && op <= SK.LastAssignment) + return refuse(e, "an assignment inside an expression"); + // Coercions of pure operands; a module-defined valueOf/toString + // runs module code, whose effects the probe's recording sees. + pure(b.left); + pure(b.right); + return; } case SK.ConditionalExpression: { const c = e as ts.ConditionalExpression; diff --git a/packages/compiler/src/node-islands/probe-recorder.ts b/packages/compiler/src/node-islands/probe-recorder.ts new file mode 100644 index 000000000..9b85b8713 --- /dev/null +++ b/packages/compiler/src/node-islands/probe-recorder.ts @@ -0,0 +1,489 @@ +/* The build-time load recorder of deferred Node islands (scope-B NI-7b). + * It runs inside libnode in the probe host (snapshot.ts writes it next to + * the host and the bootstrap requires it), loads every candidate island + * module once and records, for each: + * + * - inputs: reads of values that can differ between the build and a run, + * or between the import position and a later evaluation: process.env + * keys (guarded at boot), process.env enumeration, stdio TTY state + * (`isTTY` is guarded at boot; window size and colour depth are not), + * argv, execPath, cwd, uid/gid, the file system, os information, and + * the clock and random numbers (volatile: allowed, but then no + * constants are snapshotted); + * - effects: output, warnings, process listeners and exits, timers, + * immediates, ticks, microtasks and any other async resource created + * during the load (promises included), child processes, the network, + * workers, vm, the inspector, native addons (process.dlopen), writes + * to process.env; + * - mutations of builtin state: globalThis, the builtin prototypes and + * constructors, `process`, and the exports of common builtin modules + * (island-local, since native code has its own builtins, but + * observable by other island modules evaluated out of order); + * - every file the load required (the module's closure), and a throw; + * + * and takes the interface snapshot of the module's value (snapshot.ts). + * Calls made by Node's own internals (the module loader reading files, + * for instance) are not the module's: a record is kept only when the + * nearest caller outside this file is not `node:internal/`. + * + * Determinism argument: a load that read no input runs the same way at + * any time in any run (with the same pack and the same files, which the + * boot guards check), so one recorded run is the whole truth about its + * effects, and evaluating it later is unobservable when that run had + * none. Volatile values (pid, clock, random) do not change between the + * import position and a later evaluation in the same process except the + * clock and random numbers, which no run of Node reproduces either. */ +export const PROBE_RECORDER = String.raw`"use strict"; +const types = require("node:util").types; +const Module = require("node:module"); +const { createRequire } = Module; +const asyncHooks = require("node:async_hooks"); +const SELF = __filename; + +let lastCaller = ""; +// The closure of the module being loaded (files its load resolved), and +// its package directory: code in other files (an earlier module that +// wrapped a builtin, as graceful-fs wraps fs.closeSync) acts for its caller. +let currentFiles = null; +let currentPackageDir = null; +function frameFile(line) { + const m = /\(([^()]+):\d+:\d+\)\s*$/.exec(line) || /at ([^ ()]+):\d+:\d+\s*$/.exec(line); + if (m === null) return null; + let f = m[1]; + if (f.startsWith("file://")) { try { f = require("node:url").fileURLToPath(f); } catch {} } + return f; +} +// Whose call this is: walking out from the recorder's frames, the first +// frame of the module loader (node:internal/modules/) makes it the +// loader's (reading a module's files); the first frame of other code +// (an island module, outside node:) makes it the module's. Other builtin +// frames (console, streams, a public builtin calling its own exports) act +// for their caller. +function internalCaller() { + const limit = Error.stackTraceLimit; + Error.stackTraceLimit = 64; + const lines = new Error().stack.split("\n"); + Error.stackTraceLimit = limit; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.includes(SELF)) continue; + if (line.includes("node:internal/modules/")) return true; + if (/[( ]node:/.test(line)) continue; + const file = frameFile(line); + if (currentFiles !== null && file !== null && !currentFiles.has(file)) continue; + lastCaller = line.trim().replace(/^at /, ""); + return false; + } + return true; +} + +const fnToString = Function.prototype.toString; +const hasOwn = Object.prototype.hasOwnProperty; +function isConstructor(f) { + try { Reflect.construct(String, [], f); return true; } catch { return false; } +} +function plainClass(f) { + let source = ""; + try { source = fnToString.call(f); } catch { return false; } + if (/\[native code\]\s*\}$/.test(source)) return false; + for (let o = f; o !== null && o !== Function.prototype; o = Object.getPrototypeOf(o)) + if (hasOwn.call(o, Symbol.hasInstance)) return false; + return true; +} + +const builtinObjects = new Set(); +for (const name of ["assert", "buffer", "child_process", "constants", "crypto", "dns", "events", "fs", + "fs/promises", "http", "https", "module", "net", "os", "path", "querystring", "readline", "stream", + "stream/promises", "string_decoder", "timers", "tty", "url", "util", "util/types", "zlib"]) { + try { builtinObjects.add(require(name)); } catch {} +} + +exports.probe = function probe(input) { + const objects = {}; + const oids = new Map(); + let next = 1; + const value = (v, depth) => { + if (v === undefined) return { u: 1 }; + if (v === null) return { l: 1 }; + switch (typeof v) { + case "boolean": return { b: v }; + case "number": return { n: Object.is(v, -0) ? "-0" : String(v) }; + case "string": return v.isWellFormed() ? { s: v } : { x: 1 }; + case "symbol": return object(v, 2, depth); + case "function": + if (types.isProxy(v)) return { x: 1 }; + return object(v, 1 | (isConstructor(v) ? 32 : 0), depth); + case "object": + if (types.isProxy(v) || types.isPromise(v) || types.isNativeError(v) || + ArrayBuffer.isView(v) || types.isExternal(v) || builtinObjects.has(v)) return { x: 1 }; + return object(v, Array.isArray(v) ? 8 : 0, depth); + default: return { x: 1 }; + } + }; + const object = (v, flags, depth) => { + if (oids.has(v)) return { o: oids.get(v) }; + const id = next++; + oids.set(v, id); + const entry = { f: flags, p: (flags & 1) !== 0 && plainClass(v), m: [] }; + objects[id] = entry; + if (typeof v === "symbol") return { o: id }; + const keys = depth === 0 || (depth === 1 && typeof v !== "function") + ? Object.getOwnPropertyNames(v) + : typeof v === "function" ? ["name", "length"] : []; + for (const key of keys) { + let d; + try { d = Object.getOwnPropertyDescriptor(v, key); } catch { continue; } + if (d === undefined) continue; + if (!("value" in d)) { + // The module value's getters (esbuild's __export): read once, under + // the recording, as Node reads CommonJS named exports after the + // module evaluated. + if (depth !== 0 || typeof d.get !== "function") continue; + // A getter that reads inputs or has effects (esbuild's lazy __esm + // inits) is left out: its member starts Node, and its work happens + // when the program reads the member, eager or deferred alike (Node + // reads every CommonJS named export at import; scriptc reads the + // ones the program imports). It is not part of the load. + const marks = [inputs.length, volatile.length, envKeys.size, tty.size, effects.length]; + const keysBefore = new Set(envKeys.keys()); + const ttyBefore = new Set(tty.keys()); + let got, ok = true; + recording = true; + try { got = d.get.call(v); } catch { ok = false; } + recording = false; + const readInput = inputs.length > marks[0] || volatile.length > marks[1] || + envKeys.size > marks[2] || tty.size > marks[3] || effects.length > marks[4]; + if (readInput) { + inputs.length = marks[0]; + volatile.length = marks[1]; + effects.length = marks[4]; + for (const k of [...envKeys.keys()]) if (!keysBefore.has(k)) envKeys.delete(k); + for (const k of [...tty.keys()]) if (!ttyBefore.has(k)) tty.delete(k); + } + if (ok && !readInput) entry.m.push([key, value(got, depth + 1)]); + continue; + } + entry.m.push([key, value(d.value, depth + 1)]); + } + return { o: id }; + }; + + // ── the recorder ── + const inputs = [], volatile = [], effects = [], tty = new Map(), envKeys = new Map(); + const files = new Set(); + let recording = false; + const note = (list, what) => { + if (recording && !internalCaller()) list.push(list === volatile ? what : what + " (" + lastCaller + ")"); + }; + const undo = []; + const wrapFn = (owner, key, list, label) => { + let original; + try { original = owner[key]; } catch { return; } + if (typeof original !== "function") return; + const wrapped = function (...a) { + note(list, label); + return new.target ? Reflect.construct(original, a, new.target) : original.apply(this, a); + }; + try { + Object.defineProperty(wrapped, "name", { value: original.name }); + Object.defineProperty(wrapped, "length", { value: original.length }); + for (const k of Reflect.ownKeys(original)) + if (!hasOwn.call(wrapped, k)) Object.defineProperty(wrapped, k, Object.getOwnPropertyDescriptor(original, k)); + owner[key] = wrapped; + } catch { return; } + undo.push(() => { try { owner[key] = original; } catch {} }); + }; + const wrapGetter = (owner, key, label, onRead) => { + const d = Object.getOwnPropertyDescriptor(owner, key); + if (d === undefined || !d.configurable) return; + Object.defineProperty(owner, key, { + configurable: true, + enumerable: d.enumerable, + get() { + const v = d.get ? d.get.call(this) : d.value; + if (recording && !internalCaller()) onRead(v); + return v; + }, + set(v) { + note(effects, "a write of " + label); + if (d.set) d.set.call(this, v); else d.value = v; + }, + }); + undo.push(() => Object.defineProperty(owner, key, d)); + }; + const realEnv = process.env; + const readKey = (t, k) => { + if (recording && typeof k === "string" && !envKeys.has(k) && !internalCaller()) + envKeys.set(k, t[k] === undefined ? null : String(t[k])); + }; + process.env = new Proxy(realEnv, { + get(t, k) { readKey(t, k); return t[k]; }, + has(t, k) { readKey(t, k); return k in t; }, + getOwnPropertyDescriptor(t, k) { readKey(t, k); return Reflect.getOwnPropertyDescriptor(t, k); }, + ownKeys(t) { note(inputs, "an enumeration of process.env"); return Reflect.ownKeys(t); }, + set(t, k, v) { note(effects, "a write of process.env." + String(k)); t[k] = v; return true; }, + defineProperty(t, k, d) { note(effects, "a write of process.env." + String(k)); return Reflect.defineProperty(t, k, d); }, + deleteProperty(t, k) { note(effects, "a delete of process.env." + String(k)); delete t[k]; return true; }, + }); + undo.push(() => { process.env = realEnv; }); + for (const k of ["argv", "execArgv", "execPath", "title", "argv0", "debugPort", "exitCode"]) + wrapGetter(process, k, "process." + k, () => note(inputs, "process." + k)); + for (const k of ["pid", "ppid"]) wrapGetter(process, k, "process." + k, () => note(volatile, "process." + k)); + for (const k of ["cwd", "getuid", "getgid", "geteuid", "getegid", "getgroups", "umask", "getActiveResourcesInfo", "resourceUsage", "availableMemory", "constrainedMemory"]) + wrapFn(process, k, inputs, "process." + k + "()"); + for (const k of ["uptime", "hrtime", "memoryUsage", "cpuUsage"]) wrapFn(process, k, volatile, "process." + k + "()"); + if (process.hrtime) wrapFn(process.hrtime, "bigint", volatile, "process.hrtime.bigint()"); + for (const k of ["exit", "reallyExit", "abort", "kill", "chdir", "setuid", "setgid", "seteuid", "setegid", "setgroups", "dlopen", "binding", "_linkedBinding", "emitWarning", "on", "addListener", "once", "prependListener", "prependOnceListener", "removeListener", "off", "removeAllListeners", "setMaxListeners", "nextTick", "setUncaughtExceptionCaptureCallback", "setSourceMapsEnabled", "send", "disconnect"]) + wrapFn(process, k, effects, "process." + k + "()"); + for (const [fd, s] of [[0, "stdin"], [1, "stdout"], [2, "stderr"]]) { + let stream; + try { stream = process[s]; } catch { continue; } + for (const k of ["write", "on", "once", "addListener", "prependListener", "end", "setRawMode", "resume", "pause", "read", "cork", "uncork", "destroy", "pipe"]) + wrapFn(stream, k, effects, "process." + s + "." + k + "()"); + wrapGetter(stream, "isTTY", "process." + s + ".isTTY", (v) => { if (!tty.has(fd)) tty.set(fd, v === true); }); + for (const k of ["columns", "rows"]) wrapGetter(stream, k, "process." + s + "." + k, () => note(inputs, "process." + s + "." + k)); + for (const k of ["getColorDepth", "hasColors", "getWindowSize"]) wrapFn(stream, k, inputs, "process." + s + "." + k + "()"); + } + for (const k of ["queueMicrotask", "fetch"]) wrapFn(globalThis, k, effects, k + "()"); + // Timers: one the load sets and clears again, and a clear of nothing + // (undefined, null), leave nothing behind (NI-7d). + const pendingTimers = new Map(); + for (const k of ["setTimeout", "setInterval", "setImmediate"]) { + const original = globalThis[k]; + const wrapped = function (...a) { + const handle = original.apply(this, a); + if (recording && !internalCaller()) { + effects.push(k + "() (" + lastCaller + ")"); + pendingTimers.set(handle, effects.length - 1); + } + return handle; + }; + Object.defineProperty(wrapped, "name", { value: original.name }); + globalThis[k] = wrapped; + undo.push(() => { globalThis[k] = original; }); + } + for (const k of ["clearTimeout", "clearInterval", "clearImmediate"]) { + const original = globalThis[k]; + const wrapped = function (handle) { + if (recording && !internalCaller()) { + if (pendingTimers.has(handle)) { + effects[pendingTimers.get(handle)] = null; + pendingTimers.delete(handle); + } else if (handle !== undefined && handle !== null) { + effects.push(k + "() of a timer the load did not set (" + lastCaller + ")"); + } + } + return original.apply(this, arguments); + }; + Object.defineProperty(wrapped, "name", { value: original.name }); + globalThis[k] = wrapped; + undo.push(() => { globalThis[k] = original; }); + } + wrapFn(Date, "now", volatile, "Date.now()"); + wrapFn(Math, "random", volatile, "Math.random()"); + wrapFn(globalThis, "Date", volatile, "new Date()"); + const whole = { + fs: inputs, os: inputs, child_process: effects, net: effects, dgram: effects, dns: effects, + http: effects, https: effects, http2: effects, tls: effects, worker_threads: effects, + cluster: effects, vm: effects, inspector: effects, tty: inputs, readline: effects, timers: effects, + perf_hooks: volatile, crypto: volatile, v8: effects, repl: effects, trace_events: effects, + async_hooks: effects, diagnostics_channel: effects, + }; + // os.homedir() and os.tmpdir() are functions of the environment on POSIX: + // HOME (when set), and TMPDIR, TMP, TEMP. Their keys become env guards. + const osModule = require("node:os"); + for (const [k, keys] of [["homedir", ["HOME"]], ["tmpdir", ["TMPDIR", "TMP", "TEMP"]]]) { + const original = osModule[k]; + osModule[k] = function () { + const result = original.apply(this, arguments); + if (recording && !internalCaller()) { + if (process.platform === "win32" || (k === "homedir" && realEnv.HOME === undefined)) + inputs.push("os." + k + "()"); + else for (const key of keys) if (!envKeys.has(key)) envKeys.set(key, realEnv[key] === undefined ? null : String(realEnv[key])); + } + return result; + }; + undo.push(() => { osModule[k] = original; }); + } + const pureFns = new Set(["os.platform", "os.homedir", "os.tmpdir", "os.type", "os.arch", "os.machine", "os.endianness", + "crypto.createHash", "crypto.createHmac", "crypto.getHashes", "crypto.getCiphers", "crypto.getCurves", + "crypto.createCipheriv", "crypto.createDecipheriv", "vm.isContext", "fs.existsSync__never", + "async_hooks.AsyncLocalStorage", "async_hooks.AsyncResource", "diagnostics_channel.channel", + "diagnostics_channel.tracingChannel", "diagnostics_channel.hasSubscribers"]); + // Synchronous reads of the module's own package (its files are boot + // guards, by size and mtime) are guarded inputs, not inputs. + const fsModule = require("node:fs"); + const guardedFds = new Set(); + const ownPath = (p) => { + if (currentPackageDir === null) return null; + let f = p; + if (f instanceof URL) { try { f = require("node:url").fileURLToPath(f); } catch { return null; } } + if (Buffer.isBuffer(f)) f = f.toString(); + if (typeof f !== "string") return null; + const abs = require("node:path").resolve(f); + return abs === currentPackageDir || abs.startsWith(currentPackageDir + "/") ? abs : null; + }; + const readOnlyFlags = (flags) => flags === undefined || flags === "r" || flags === "rs" || flags === fsModule.constants.O_RDONLY; + for (const key of ["readFileSync", "existsSync", "statSync", "lstatSync", "readdirSync", "realpathSync", "accessSync", "readlinkSync", "openSync"]) { + const original = fsModule[key]; + if (typeof original !== "function") continue; + const wrapped = function (target, flags) { + const result = original.apply(this, arguments); + if (recording && !internalCaller()) { + const own = ownPath(target); + if (own !== null && (key !== "openSync" || readOnlyFlags(flags))) { + files.add(own); + if (key === "openSync") guardedFds.add(result); + } else { + inputs.push("fs." + key + "() (" + lastCaller + ")"); + } + } + return result; + }; + for (const k of Reflect.ownKeys(original)) if (!hasOwn.call(wrapped, k)) { try { Object.defineProperty(wrapped, k, Object.getOwnPropertyDescriptor(original, k)); } catch {} } + fsModule[key] = wrapped; + undo.push(() => { fsModule[key] = original; }); + } + for (const key of ["readSync", "fstatSync", "closeSync"]) { + const original = fsModule[key]; + const wrapped = function (fd) { + const result = original.apply(this, arguments); + if (recording && !internalCaller()) { + if (!guardedFds.has(fd)) inputs.push("fs." + key + "() (" + lastCaller + ")"); + else if (key === "closeSync") guardedFds.delete(fd); + } + return result; + }; + fsModule[key] = wrapped; + undo.push(() => { fsModule[key] = original; }); + } + const guardedFsFns = new Set(["readFileSync", "existsSync", "statSync", "lstatSync", "readdirSync", "realpathSync", "accessSync", "readlinkSync", "openSync", "readSync", "fstatSync", "closeSync"]); + for (const [name, list] of Object.entries(whole)) { + let m; + try { m = require(name); } catch { continue; } + for (const key of Object.keys(m)) { + if (name === "fs" && guardedFsFns.has(key)) continue; + if (pureFns.has(name + "." + key) || key === "promises" || /^[A-Z]/.test(key) && name !== "worker_threads" && name !== "child_process") continue; + wrapFn(m, key, list, name + "." + key + "()"); + } + if (name === "fs" && m.promises) + for (const key of Object.keys(m.promises)) wrapFn(m.promises, key, inputs, "fs.promises." + key + "()"); + } + wrapFn(require("worker_threads"), "Worker", effects, "new Worker()"); + // Async resources the load creates. A promise alone is no work (an async + // function that returns at once creates one); a reaction to a promise + // the load created (then/catch/finally, an await) is. + const loadPromises = new Set(); + const hook = asyncHooks.createHook({ + init(id, type, trigger) { + // Timers and immediates are the timer wrappers' to account for. + if (type === "Timeout" || type === "Immediate" || !recording) return; + if (type === "PROMISE") { + if (loadPromises.has(trigger)) { + internalCaller(); + effects.push("a promise reaction (" + lastCaller + ")"); + } else if (!internalCaller()) { + loadPromises.add(id); + } + return; + } + if (!internalCaller()) effects.push("async work (" + type + ", " + lastCaller + ")"); + }, + }); + hook.enable(); + undo.push(() => hook.disable()); + // The closure: a resolve hook (no frame of ours stays on the stack while + // modules evaluate, so Node's node_modules checks see the real callers). + const hooks = Module.registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + if (recording && typeof result.url === "string" && result.url.startsWith("file:")) + files.add(require("node:url").fileURLToPath(result.url)); + return result; + }, + }); + undo.push(() => hooks.deregister()); + const watched = [ + ["globalThis", globalThis], ["Object", Object], ["Object.prototype", Object.prototype], + ["Function.prototype", Function.prototype], ["Array", Array], ["Array.prototype", Array.prototype], + ["String.prototype", String.prototype], ["Number.prototype", Number.prototype], + ["Boolean.prototype", Boolean.prototype], ["Symbol", Symbol], ["Symbol.prototype", Symbol.prototype], + ["Promise", Promise], ["Promise.prototype", Promise.prototype], ["Error", Error], + ["Error.prototype", Error.prototype], ["RegExp.prototype", RegExp.prototype], + ["Map.prototype", Map.prototype], ["Set.prototype", Set.prototype], ["JSON", JSON], + ["Math", Math], ["Reflect", Reflect], ["process", process], ["Buffer", Buffer], + ["Buffer.prototype", Buffer.prototype], + ]; + for (const name of ["fs", "path", "events", "stream", "util", "os", "child_process", "http", "https", + "net", "url", "buffer", "crypto", "zlib", "module", "assert", "string_decoder", "timers", "tty", + "fs/promises", "stream/promises", "util/types", "constants", "querystring", "readline"]) { + try { watched.push([name, require(name)]); } catch {} + } + try { watched.push(["events.prototype", require("events").prototype]); } catch {} + try { watched.push(["stream.prototype", require("stream").prototype]); } catch {} + const shot = () => watched.map(([name, o]) => { + const m = new Map(); + for (const k of Reflect.ownKeys(o)) { + const d = Object.getOwnPropertyDescriptor(o, k); + m.set(k, d === undefined ? undefined : "value" in d ? d.value : d.get); + } + return [name, m]; + }); + + const results = []; + for (const path of input.modules) { + inputs.length = volatile.length = effects.length = 0; + pendingTimers.clear(); + loadPromises.clear(); + tty.clear(); envKeys.clear(); files.clear(); + files.add(path); + currentFiles = files; + currentPackageDir = null; + for (let d = require("node:path").dirname(path); d !== require("node:path").dirname(d); d = require("node:path").dirname(d)) { + if (require("node:fs").existsSync(d + "/package.json")) { currentPackageDir = d; break; } + } + const before = shot(); + let exportsValue, threw; + recording = true; + try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); } + recording = false; + const after = shot(); + const root = threw === undefined ? value(exportsValue, 0) : undefined; + const mutations = []; + for (let i = 0; i < before.length; i++) { + const [name, b] = before[i]; + const a = after[i][1]; + for (const [k, v] of a) if (!b.has(k) || !Object.is(b.get(k), v)) mutations.push(name + "." + String(k)); + for (const k of b.keys()) if (!a.has(k)) mutations.push("delete " + name + "." + String(k)); + } + // Node's own undici creates its global-dispatcher symbol lazily, at + // times unrelated to the module; only code that names it can set it. + const names = (key) => { + for (const f of files) { + try { if (require("node:fs").readFileSync(f, "utf8").includes(key)) return true; } catch {} + } + return false; + }; + for (let i = mutations.length - 1; i >= 0; i--) { + const m = /^globalThis\.Symbol\((undici\.globalDispatcher\.\d+)\)$/.exec(mutations[i]); + if (m !== null && !names("undici.globalDispatcher")) mutations.splice(i, 1); + } + const record = { + path, + inputs: [...new Set(inputs)], + volatile: [...new Set(volatile)], + effects: [...new Set(effects.filter((x) => x !== null))], + mutations, + env: [...envKeys], + tty: [...tty], + files: [...files], + }; + if (threw !== undefined) { record.throws = threw; results.push(record); continue; } + if (!("o" in root)) { record.reason = "its value is not an object"; results.push(record); continue; } + record.root = root.o; + results.push(record); + } + for (const u of undo.reverse()) u(); + return { results, objects }; +}; +`; diff --git a/packages/compiler/src/node-islands/snapshot.ts b/packages/compiler/src/node-islands/snapshot.ts index bde0f9674..ab5aeb27b 100644 --- a/packages/compiler/src/node-islands/snapshot.ts +++ b/packages/compiler/src/node-islands/snapshot.ts @@ -7,16 +7,23 @@ * decides each one: * * - the package's `evaluate` setting (`scriptc.packages.`): - * "eager" keeps the module eager; "lazy" skips the static effect gate - * and the probe's effect log (the user judged the effects benign); - * "auto" (the default) needs both; - * - the static effect gate (effect-gate.ts) over the module's closure; + * "eager" keeps the module eager; "lazy" defers it whatever its load + * does (the user judged the effects benign); * - the probe: one run of the build-time probe host * (runtime src/tools/scr_node_island_probe.c) in a libnode environment - * from the same pack, which loads every candidate module, records what - * it can observe of their evaluation (output, warnings, listeners, - * timers, new globals, a throw) and takes the interface snapshot of - * each module's value; + * from the same pack, which loads every candidate module under the + * load recorder (probe-recorder.ts) and takes the interface snapshot of + * each module's value. "auto" (the default) defers a module whose + * recorded load threw nothing, had no effects and read no run-time + * input other than env keys and stdio TTY state, which become boot + * guards (the determinism argument in probe-recorder.ts). The static + * effect gate (effect-gate.ts) is reported alongside, as the tier that + * needs no recording; + * - per-module mode (NI-7b): when every static island import defers, + * none mutates builtin state and no island module loads at a call site, + * Node starting evaluates nothing by itself; components of deferred + * modules that share files evaluate on first use. Otherwise every + * deferred module evaluates when Node starts (in import order); * * and rewrites the call: a deferred module keeps `nodeIsland.defer` with * its snapshot text (format: runtime scr_node_island_boot.c), any other @@ -40,6 +47,7 @@ import type { IrExpr, IrModule } from "../ir/ir.js"; import { readProjectConfig } from "../project-config.js"; import { effectGate } from "./effect-gate.js"; import { packageDirOf } from "./origin.js"; +import { PROBE_RECORDER } from "./probe-recorder.js"; type DeferCall = Extract; @@ -87,6 +95,30 @@ function traceDecision(d: DeferralDecision): void { ); } +/** Island loads lowering emitted at call sites (`require()`, `import()`). */ +function hasDynamicLoads(mod: IrModule): boolean { + let found = false; + const seen = new Set(); + const walk = (v: unknown): void => { + if (found || v === null || typeof v !== "object" || seen.has(v)) return; + seen.add(v); + if (Array.isArray(v)) { + for (const x of v) walk(x); + return; + } + const o = v as Record; + if ( + o["kind"] === "libCall" && + (o["fn"] === "nodeIsland.require" || o["fn"] === "nodeIsland.import") + ) + found = true; + for (const k in o) if (k !== "loc" && k !== "type") walk(o[k]); + }; + walk(mod.functions); + walk(mod.globals); + return found; +} + export function finalizeDeferredIslands( mod: IrModule, entryPath: string, @@ -94,6 +126,7 @@ export function finalizeDeferredIslands( ): DeferralDecision[] { const calls = deferCalls(mod); if (calls.length === 0) return []; + const dynamicLoads = hasDynamicLoads(mod); const decisions: DeferralDecision[] = []; const decide = (call: DeferCall, deferred: boolean, reason: string): void => { const d = { path: pathOf(call), deferred, reason }; @@ -106,7 +139,7 @@ export function finalizeDeferredIslands( return decisions; } const config = readProjectConfig(entryPath); - const candidates: { call: DeferCall; path: string; lazy: boolean; files: string[] }[] = []; + const candidates: { call: DeferCall; path: string; lazy: boolean; gate: string }[] = []; for (const call of calls) { const path = pathOf(call); const pkg = packageDirOf(path); @@ -116,17 +149,14 @@ export function finalizeDeferredIslands( continue; } const gate = effectGate(path); - if (!gate.ok && evaluate !== "lazy") { - decide(call, false, `${gate.reason} in ${gate.file}`); - continue; - } candidates.push({ call, path, lazy: evaluate === "lazy", - files: gate.ok ? gate.files : [path], + gate: gate.ok ? "" : `${gate.reason} in ${gate.file}`, }); } + let allDeferred = candidates.length === calls.length; if (candidates.length === 0) return decisions; const probe = runProbe(packDir, [...new Set(candidates.map((c) => c.path))]); if (typeof probe === "string") { @@ -134,24 +164,71 @@ export function finalizeDeferredIslands( return decisions; } const packGuards = packGuardLines(packDir); + const deferred: { call: DeferCall; result: ProbeResult; why: string }[] = []; for (const c of candidates) { const result = probe.results.find((r) => r.path === c.path); - if (result?.throws !== undefined) { - decide(c.call, false, `its evaluation throws: ${result.throws}`); + const why = (() => { + if (result === undefined) return "the probe did not load it"; + if (result.throws !== undefined) return `its evaluation throws: ${result.throws}`; + if (result.root === undefined) return result.reason ?? "the probe did not load it"; + if (c.lazy) return null; + if (result.effects.length > 0) return `its load has effects: ${result.effects.join(", ")}`; + if (result.inputs.length > 0) return `its load reads ${result.inputs.join(", ")}`; + return null; + })(); + if (why !== null || result === undefined) { + allDeferred = false; + decide(c.call, false, why ?? ""); continue; } - if (result === undefined || result.root === undefined) { - decide(c.call, false, result?.reason ?? "the probe did not load it"); - continue; - } - if (result.effects.length > 0 && !c.lazy) { - decide(c.call, false, `the probe saw ${result.effects.join(", ")}`); - continue; - } - const text = encodeSnapshot(probe, result.root, [...packGuards, ...fileGuardLines(c.files)]); - const arg = c.call.args[1]; + const how = c.lazy + ? `"evaluate": "lazy"` + : c.gate === "" + ? "static gate" + : `recorded load is deterministic (static gate: ${c.gate})`; + deferred.push({ call: c.call, result, why: how }); + } + // Per-module evaluation once Node runs (no materialize-all at start): + // every static island import is deferred, none mutates builtin state, + // and the program loads no island module at a call site. Deferred + // modules whose closures share files form one component, evaluated + // together in import order. + const lazyMode = + allDeferred && !dynamicLoads && deferred.every((d) => d.result.mutations.length === 0); + const component = new Map(); + if (lazyMode) { + const parent = deferred.map((_, i) => i); + const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!))); + const owner = new Map(); + deferred.forEach((d, i) => { + for (const f of d.result.files) { + const j = owner.get(f); + if (j === undefined) owner.set(f, i); + else parent[find(i)] = find(j); + } + }); + deferred.forEach((d, i) => component.set(d.result.path, find(i) + 1)); + } + for (const d of deferred) { + const r = d.result; + const guards = [ + ...packGuards, + ...fileGuardLines(r.files), + ...r.env.map(([k, v]) => `E ${lstr(k)} ${v === null ? "0 0:" : `1 ${lstr(v)}`}`), + ...r.tty.map(([fd, isTty]) => `T ${fd} ${isTty ? 1 : 0}`), + ...(lazyMode ? [`M ${component.get(r.path)}`] : []), + ]; + const text = encodeSnapshot(probe, r.root!, guards, r.volatile.length === 0); + const arg = d.call.args[1]; if (arg !== undefined && arg.kind === "strLit") (arg as { value: string }).value = text; - decide(c.call, true, ""); + const notes = [ + d.why, + ...(r.env.length > 0 ? [`env guards ${r.env.map(([k]) => k).join(",")}`] : []), + ...(r.volatile.length > 0 ? [`volatile ${r.volatile.join(",")}: no constants`] : []), + ...(r.mutations.length > 0 ? [`mutates ${r.mutations.join(",")}`] : []), + lazyMode ? `per-module (component ${component.get(r.path)})` : "evaluated when Node starts", + ]; + decide(d.call, true, notes.join("; ")); } return decisions; } @@ -213,8 +290,22 @@ interface ProbeObject { m: [string, ProbeValue][]; } +interface ProbeResult { + path: string; + root?: number; + inputs: string[]; + volatile: string[]; + effects: string[]; + mutations: string[]; + env: [string, string | null][]; + tty: [number, boolean][]; + files: string[]; + throws?: string; + reason?: string; +} + interface ProbeOutput { - results: { path: string; root?: number; effects: string[]; throws?: string; reason?: string }[]; + results: ProbeResult[]; objects: Record; } @@ -228,7 +319,12 @@ function encodeValue(v: ProbeValue): string | null { return null; } -function encodeSnapshot(probe: ProbeOutput, root: number, guards: string[]): string { +function encodeSnapshot( + probe: ProbeOutput, + root: number, + guards: string[], + constants: boolean, +): string { const lines = [...guards]; const reached: number[] = []; const seen = new Set(); @@ -246,7 +342,7 @@ function encodeSnapshot(probe: ProbeOutput, root: number, guards: string[]): str for (const oid of reached) { for (const [key, v] of probe.objects[oid]!.m) { const enc = encodeValue(v); - if (enc !== null) lines.push(`K ${oid} ${lstr(key)} ${enc}`); + if (enc !== null && (constants || "o" in v)) lines.push(`K ${oid} ${lstr(key)} ${enc}`); } } lines.push(`R ${root}`); @@ -256,16 +352,16 @@ function encodeSnapshot(probe: ProbeOutput, root: number, guards: string[]): str /* ── the probe ────────────────────────────────────────────────────────── */ /** The bootstrap the probe host starts Node with: the dispatcher the - * bridge requires, and the probe as its `call`. */ -const PROBE_BOOTSTRAP = String.raw` -"use strict"; -const types = require("node:util").types; -const { createRequire } = require("node:module"); + * bridge requires, whose `call` runs the recorder (probe-recorder.ts) + * from `recorder`. */ +function probeBootstrap(recorder: string): string { + return `"use strict"; const noop = () => {}; globalThis.__scriptcNodeIsland = { call(id, sync, file, specifier, name, args) { try { - return "R" + JSON.stringify(probe(JSON.parse(args))); + const recorder = require("node:module").createRequire(${JSON.stringify(recorder)})(${JSON.stringify(recorder)}); + return "R" + JSON.stringify(recorder.probe(JSON.parse(args))); } catch (e) { return "T" + JSON.stringify(String((e && e.stack) || e)); } @@ -274,110 +370,17 @@ globalThis.__scriptcNodeIsland = { setImmediate: noop, clearImmediate: noop, refImmediate: noop, nextTick: noop, emit: noop, load: noop, sharedPush: noop, }; -const fnToString = Function.prototype.toString; -const hasOwn = Object.prototype.hasOwnProperty; -function isConstructor(f) { - try { Reflect.construct(String, [], f); return true; } catch { return false; } -} -function plainClass(f) { - let source = ""; - try { source = fnToString.call(f); } catch { return false; } - if (/\[native code\]\s*\}$/.test(source)) return false; - for (let o = f; o !== null && o !== Function.prototype; o = Object.getPrototypeOf(o)) - if (hasOwn.call(o, Symbol.hasInstance)) return false; - return true; -} -function probe(input) { - const objects = {}; - const oids = new Map(); - let next = 1; - const value = (v, depth) => { - if (v === undefined) return { u: 1 }; - if (v === null) return { l: 1 }; - switch (typeof v) { - case "boolean": return { b: v }; - case "number": return { n: Object.is(v, -0) ? "-0" : String(v) }; - case "string": return v.isWellFormed() ? { s: v } : { x: 1 }; - case "symbol": return object(v, 2, depth); - case "function": - if (types.isProxy(v)) return { x: 1 }; - return object(v, 1 | (isConstructor(v) ? 32 : 0), depth); - case "object": - if (types.isProxy(v) || types.isPromise(v) || types.isNativeError(v) || - ArrayBuffer.isView(v) || types.isExternal(v)) return { x: 1 }; - return object(v, Array.isArray(v) ? 8 : 0, depth); - default: return { x: 1 }; - } - }; - const object = (v, flags, depth) => { - if (oids.has(v)) return { o: oids.get(v) }; - const id = next++; - oids.set(v, id); - const entry = { f: flags, p: (flags & 1) !== 0 && plainClass(v), m: [] }; - objects[id] = entry; - if (typeof v === "symbol") return { o: id }; - // The module's value and its direct members: every own data property - // (namespace member lists); functions anywhere: name and length. - const keys = depth === 0 || (depth === 1 && typeof v !== "function") - ? Object.getOwnPropertyNames(v) - : typeof v === "function" ? ["name", "length"] : []; - for (const key of keys) { - let d; - try { d = Object.getOwnPropertyDescriptor(v, key); } catch { continue; } - if (d === undefined || !("value" in d)) continue; - entry.m.push([key, value(d.value, depth + 1)]); - } - return { o: id }; - }; - const effects = []; - const watch = (owner, key, label) => { - const original = owner[key]; - if (typeof original !== "function") return () => {}; - owner[key] = function (...a) { effects.push(label); return original.apply(this, a); }; - return () => { owner[key] = original; }; - }; - const results = []; - for (const path of input.modules) { - effects.length = 0; - const globals = new Set(Reflect.ownKeys(globalThis)); - const restore = [ - watch(process.stdout, "write", "stdout output"), - watch(process.stderr, "write", "stderr output"), - watch(process, "emitWarning", "a process warning"), - watch(process, "on", "a process listener"), - watch(process, "addListener", "a process listener"), - watch(process, "once", "a process listener"), - watch(process, "prependListener", "a process listener"), - watch(process, "nextTick", "process.nextTick"), - watch(globalThis, "setTimeout", "a timer"), - watch(globalThis, "setInterval", "a timer"), - watch(globalThis, "setImmediate", "an immediate"), - watch(globalThis, "queueMicrotask", "a microtask"), - ]; - let exportsValue, threw; - try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); } - for (const r of restore) r(); - for (const k of Reflect.ownKeys(globalThis)) - if (!globals.has(k)) effects.push("a new global " + String(k)); - if (threw !== undefined) { results.push({ path, effects: [...effects], throws: threw }); continue; } - const root = value(exportsValue, 0); - if (!("o" in root)) { - results.push({ path, effects: [...effects], reason: "its value is not an object" }); - continue; - } - results.push({ path, root: root.o, effects: [...new Set(effects)] }); - } - return { results, objects }; -} `; +} function runtimeSrc(): string { const require = createRequire(import.meta.url); return join(dirname(require.resolve("@scriptc/runtime/package.json")), "src"); } -/** The probe host executable, built once per probe source and bridge - * header (cached under the temporary directory). */ +/** The probe host executable with its bootstrap and recorder, built once + * per probe source, bridge header and recorder (cached under the + * temporary directory). */ function probeHost(): string | string[] { const src = runtimeSrc(); const source = join(src, "tools", "scr_node_island_probe.c"); @@ -385,13 +388,15 @@ function probeHost(): string | string[] { const hash = createHash("sha256") .update(readFileSync(source)) .update(readFileSync(header)) - .update(PROBE_BOOTSTRAP) + .update(PROBE_RECORDER) + .update(probeBootstrap("")) .digest("hex") .slice(0, 16); const dir = join(tmpdir(), `scriptc-node-probe-${process.getuid?.() ?? 0}`, hash); const exe = join(dir, "scr-node-probe"); const bootstrap = join(dir, "bootstrap.js"); - if (existsSync(exe) && existsSync(bootstrap)) return [exe, bootstrap]; + const recorder = join(dir, "recorder.cjs"); + if (existsSync(exe) && existsSync(bootstrap) && existsSync(recorder)) return [exe, bootstrap]; mkdirSync(dir, { recursive: true }); const tmp = `${exe}.${process.pid}`; const errors: string[] = []; @@ -399,7 +404,9 @@ function probeHost(): string | string[] { if (cc === undefined || cc === "") continue; const r = spawnSync(cc, ["-O1", "-I", src, source, "-o", tmp, "-ldl"], { encoding: "utf8" }); if (r.status === 0) { - writeFileSync(`${bootstrap}.${process.pid}`, PROBE_BOOTSTRAP); + writeFileSync(`${recorder}.${process.pid}`, PROBE_RECORDER); + renameSync(`${recorder}.${process.pid}`, recorder); + writeFileSync(`${bootstrap}.${process.pid}`, probeBootstrap(recorder)); renameSync(`${bootstrap}.${process.pid}`, bootstrap); renameSync(tmp, exe); return [exe, bootstrap]; @@ -413,24 +420,32 @@ function runProbe(packDir: string, modules: string[]): ProbeOutput | string { const host = probeHost(); if (typeof host === "string") return host; const [exe, bootstrap] = host as [string, string]; - const input = join(dirname(exe), `input.${process.pid}.${Date.now()}.json`); + const stem = join(dirname(exe), `run.${process.pid}.${Date.now()}`); + const input = `${stem}.in.json`; + const output = `${stem}.out.json`; writeFileSync(input, JSON.stringify({ modules })); let out: string; try { - out = execFileSync(exe, [packDir, bootstrap, input], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - maxBuffer: 64 * 1024 * 1024, + execFileSync(exe, [packDir, bootstrap, input, output], { + stdio: ["ignore", "ignore", "pipe"], timeout: 60_000, }); + out = readFileSync(output, "utf8"); } catch (e) { - const err = e as { stderr?: string; message: string }; - return `the probe failed: ${(err.stderr ?? "").trim().split("\n")[0] || err.message}`; - } - try { - rmSync(input); - } catch { - /* best effort */ + const err = e as { stderr?: Buffer | string; message: string }; + return `the probe failed: ${ + String(err.stderr ?? "") + .trim() + .split("\n")[0] || err.message + }`; + } finally { + for (const f of [input, output]) { + try { + rmSync(f); + } catch { + /* best effort */ + } + } } if (!out.startsWith("R")) return `the probe threw: ${out.slice(1, 400)}`; return JSON.parse(out.slice(1)) as ProbeOutput; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6843-node-island-deferred-per-module/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6843-node-island-deferred-per-module/main.ts.json new file mode 100644 index 000000000..11715bb4f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6843-node-island-deferred-per-module/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6843-node-island-deferred-per-module/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6844-node-island-deferred-env/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6844-node-island-deferred-env/main.ts.json new file mode 100644 index 000000000..67f2ff109 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6844-node-island-deferred-env/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6844-node-island-deferred-env/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6845-node-island-deferred-require/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6845-node-island-deferred-require/main.ts.json new file mode 100644 index 000000000..5620823dc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6845-node-island-deferred-require/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6845-node-island-deferred-require/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6846-node-island-deferred-own-files/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6846-node-island-deferred-own-files/main.ts.json new file mode 100644 index 000000000..64864302d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6846-node-island-deferred-own-files/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6846-node-island-deferred-own-files/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6847-node-island-punycode-warning/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6847-node-island-punycode-warning/main.ts.json new file mode 100644 index 000000000..333e3205b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6847-node-island-punycode-warning/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6847-node-island-punycode-warning/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index 1c6bfbea3..a178d35e3 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -688,10 +688,11 @@ static void ni_start(void) { ni_node_version, bridge->node_version, bridge->module_version, dir); ni_fatal(message); } - ScrStr *self = scr_process_exec_path(); - char argv0[4096]; - snprintf(argv0, sizeof argv0, "%.*s", (int)self->len, self->data); - scr_str_release(self); + /* Node's argv[0] is "node": island process.argv0 reads it, and Node's + * warning hint is built from it ("(Use `node --trace-deprecation ...`"), + * which the runtime's own warnings print too. process.execPath stays the + * executable (libuv's exepath). */ + char argv0[] = "node"; /* Flush first: Node writes to the same stdout. */ fflush(stdout); ni_bridge = bridge; diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 2241f3fc2..8dd800997 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -174,8 +174,10 @@ void *scr_ni_stub_of(const ScrDyn *value); /* the stub entry of an unmaterialize bool scr_ni_stub_reconcile(ScrDyn *stub, ScrDyn *real); ScrDyn *scr_ni_boot_member(void *stub, const char *key, size_t len); /* +1 or NULL: unknown */ bool scr_ni_boot_plain_class(void *stub); +bool scr_ni_boot_snapshot_valid(void *stub); /* its module has not evaluated */ const char *scr_ni_pack_dir(void); /* the libnode pack directory this run uses */ void scr_ni_boot_stub_failed(void *stub); /* raises the module's load error */ +void scr_ni_boot_materialize(void *stub); /* evaluates the stub's module (component) */ /* Called at the end of Node's start (scr_node_island.c), set by the boot unit. */ extern void (*scr_ni_started_hook)(void); diff --git a/packages/runtime/src/scr_node_island_boot.c b/packages/runtime/src/scr_node_island_boot.c index 51c95f51e..fe28d76cd 100644 --- a/packages/runtime/src/scr_node_island_boot.c +++ b/packages/runtime/src/scr_node_island_boot.c @@ -33,6 +33,9 @@ * O an object (SCR_NVF_* flags) * K : a data member of object * R the module's value + * E : <0|1> : an env key the load read (absent: 0) + * T <0|1> the isTTY state the load read + * M per-module mode (NI-7b): see below * where is o, u, l, t, f, n or s:. */ #define _XOPEN_SOURCE 700 #define _DARWIN_C_SOURCE 1 @@ -45,6 +48,7 @@ #include #include #include +#include #if !defined(_WIN32) && !defined(__wasi__) @@ -76,8 +80,18 @@ typedef struct NbStub { ScrCaught *error; /* why it could not be reconciled */ } NbStub; +typedef struct { + const char *key, *value; + size_t key_len, value_len; + bool present; +} NbEnv; + typedef struct NbModule { char *path; + uint32_t component; /* per-module mode: modules evaluated together */ + bool done; + NbEnv *env; + size_t nenv; char *snapshot; /* owns the strings the stubs point into */ uint32_t root; uint32_t *order; /* stubs to reconcile after the root, parents first */ @@ -90,6 +104,12 @@ static size_t nb_stubs_cap = 0; static NbModule **nb_modules = NULL; static size_t nb_nmodules = 0, nb_modules_cap = 0; static bool nb_installed = false; +/* Per-module mode (NI-7b, `M` records): Node starting does not evaluate + * every deferred module; each component evaluates on the first use of one + * of its stubs. The build chooses it only when every static island import + * is deferred, none mutates builtin state and no island module loads at a + * call site, so nothing but these stubs can reach the modules' objects. */ +static bool nb_lazy = false; static int nb_trace = -1; static bool nb_tracing(void) { @@ -195,10 +215,63 @@ static ScrCaught *nb_changed(const NbModule *m) { return scr_exc_take(); } +/* A NUL-terminated copy (the snapshot's strings are length-delimited). */ +static char *nb_cstr(const char *s, size_t n) { + char *c = malloc(n + 1); + if (c == NULL) nb_oom(); + memcpy(c, s, n); + c[n] = '\0'; + return c; +} + +static bool nb_env_matches(const NbEnv *e) { + char *key = nb_cstr(e->key, e->key_len); + const char *now = getenv(key); + free(key); + if (!e->present) return now == NULL; + return now != NULL && strlen(now) == e->value_len && memcmp(now, e->value, e->value_len) == 0; +} + +/* The module evaluates against the environment it saw at its import + * position (the boot guard checked those values then): keys the program + * changed since are set back for the evaluation and restored after it. */ +static void nb_env_swap(NbModule *m, char **saved, bool restore) { + for (size_t i = 0; i < m->nenv; i++) { + NbEnv *e = &m->env[i]; + char *key = nb_cstr(e->key, e->key_len); + if (!restore) { + const char *now = getenv(key); + saved[i] = now != NULL ? strdup(now) : NULL; + if (!nb_env_matches(e)) { + if (e->present) { + char *v = nb_cstr(e->value, e->value_len); + setenv(key, v, 1); + free(v); + } else { + unsetenv(key); + } + } + } else { + if (saved[i] != NULL) setenv(key, saved[i], 1); + else unsetenv(key); + free(saved[i]); + } + free(key); + } +} + static void nb_materialize(NbModule *m) { + if (m->done) return; + m->done = true; + if (nb_tracing()) fprintf(stderr, "[node-islands] evaluating deferred %s\n", m->path); + char **saved = m->nenv > 0 ? calloc(m->nenv, sizeof *saved) : NULL; + if (m->nenv > 0 && saved == NULL) nb_oom(); + nb_env_swap(m, saved, false); ScrStr *path = scr_str_new(m->path, strlen(m->path)); ScrDyn *real = scr_ni_require(path); scr_str_release(path); + nb_env_swap(m, saved, true); + free(saved); if (real == NULL) { m->error = scr_exc_take(); return; @@ -210,9 +283,23 @@ static void nb_materialize(NbModule *m) { m->error = nb_changed(m); return; } + /* Only stubs the program holds (their box has a reference besides this + * table's), and their parents, need their real values: a stub nobody + * took has no identity to keep, and reading its value would run getters + * the eager program never runs. Parents precede children in `order`. */ + bool *needed = m->norder > 0 ? calloc(m->norder, sizeof *needed) : NULL; + if (m->norder > 0 && needed == NULL) nb_oom(); + for (size_t i = m->norder; i-- > 0;) { + NbStub *s = nb_stub(m->order[i]); + if (s->box->rc > 1) needed[i] = true; + if (!needed[i]) continue; + for (size_t j = 0; j < i; j++) + if (m->order[j] == s->parent) needed[j] = true; + } for (size_t i = 0; i < m->norder; i++) { NbStub *s = nb_stub(m->order[i]); NbStub *parent = nb_stub(s->parent); + if (!needed[i]) continue; if (scr_ni_stub_of(s->box) == NULL) continue; /* reached through an earlier module */ if (parent->error != NULL || scr_ni_stub_of(parent->box) != NULL) { s->error = scr_caught_retain(parent->error != NULL ? parent->error : m->error); @@ -226,14 +313,25 @@ static void nb_materialize(NbModule *m) { if (!scr_ni_stub_reconcile(s->box, value)) s->error = nb_changed(m); scr_dyn_release(value); } + free(needed); } static void nb_started(void) { + if (nb_lazy) return; /* each component evaluates on first use */ if (nb_tracing() && nb_nmodules > 0) fprintf(stderr, "[node-islands] Node started: evaluating %zu deferred module(s)\n", nb_nmodules); for (size_t i = 0; i < nb_nmodules; i++) nb_materialize(nb_modules[i]); } +void scr_ni_boot_materialize(void *stub) { + NbModule *owner = ((NbStub *)stub)->module; + for (size_t i = 0; i < nb_nmodules; i++) { + NbModule *m = nb_modules[i]; + if (m == owner || (owner->component != 0 && m->component == owner->component)) + nb_materialize(m); + } +} + void scr_ni_boot_stub_failed(void *stub) { NbStub *s = stub; ScrCaught *error = s->error != NULL ? s->error : s->module->error; @@ -277,6 +375,14 @@ ScrDyn *scr_ni_boot_member(void *stub, const char *key, size_t len) { bool scr_ni_boot_plain_class(void *stub) { return ((NbStub *)stub)->plain; } +/* Whether the snapshot still describes the stub's value: its module has + * not evaluated. Before Node starts nothing has; in per-module mode, a + * component's objects are unreachable to any code until it evaluates. */ +bool scr_ni_boot_snapshot_valid(void *stub) { + NbStub *s = stub; + return !s->module->done && (nb_lazy || scr_ni_bridge_loaded() == NULL); +} + /* ── nodeIsland.defer ─────────────────────────────────────────────────── */ static void nb_cleanup(void) { @@ -295,6 +401,7 @@ static void nb_cleanup(void) { NbModule *m = nb_modules[i]; if (m->error != NULL) scr_caught_release(m->error); free(m->order); + free(m->env); free(m->snapshot); free(m->path); free(m); @@ -340,13 +447,40 @@ static NbStub *nb_new_stub(uint32_t oid, uint32_t flags, bool plain, NbModule *m } ScrDyn *scr_node_island_defer(ScrStr *path, ScrStr *snapshot) { - if (scr_ni_bridge_loaded() != NULL) return nb_eager(path, "Node is already running"); - nb_install(); - /* Pass 1: the guards. */ + /* Pass 1: the guards, and the mode. */ NbCursor c = {snapshot->data, snapshot->data + snapshot->len, false}; + bool lazy = false; while (c.p < c.end && !c.bad) { char tag = *c.p++; - if (tag == 'G') { + if (tag == 'M') lazy = true; + nb_line_end(&c); + } + /* In per-module mode a module can stay deferred while Node runs. */ + if (!lazy && scr_ni_bridge_loaded() != NULL) return nb_eager(path, "Node is already running"); + nb_install(); + c = (NbCursor){snapshot->data, snapshot->data + snapshot->len, false}; + while (c.p < c.end && !c.bad) { + char tag = *c.p++; + if (tag == 'E') { + NbEnv e = {0}; + e.key = nb_str(&c, &e.key_len); + e.present = nb_uint(&c) != 0; + e.value = nb_str(&c, &e.value_len); + if (c.bad) break; + if (!nb_env_matches(&e)) { + if (nb_tracing()) + fprintf(stderr, "[node-islands] boot guard failed: env %.*s\n", (int)e.key_len, e.key); + return nb_eager(path, "a boot guard failed"); + } + } else if (tag == 'T') { + int fd = (int)nb_uint(&c); + bool was = nb_uint(&c) != 0; + if (c.bad) break; + if ((isatty(fd) == 1) != was) { + if (nb_tracing()) fprintf(stderr, "[node-islands] boot guard failed: isatty(%d)\n", fd); + return nb_eager(path, "a boot guard failed"); + } + } else if (tag == 'G') { size_t len; const char *file = nb_str(&c, &len); uint64_t size = nb_uint(&c); @@ -409,11 +543,26 @@ ScrDyn *scr_node_island_defer(ScrStr *path, ScrStr *snapshot) { } } else if (tag == 'R') { m->root = (uint32_t)nb_uint(&c); + } else if (tag == 'M') { + m->component = (uint32_t)nb_uint(&c); + nb_lazy = true; + } else if (tag == 'E') { + NbEnv e = {0}; + e.key = nb_str(&c, &e.key_len); + e.present = nb_uint(&c) != 0; + e.value = nb_str(&c, &e.value_len); + if (!c.bad) { + NbEnv *grown = realloc(m->env, (m->nenv + 1) * sizeof *grown); + if (grown == NULL) nb_oom(); + m->env = grown; + m->env[m->nenv++] = e; + } } nb_line_end(&c); } NbStub *root = nb_stub(m->root); if (c.bad || root == NULL) { + free(m->env); free(m->snapshot); free(m->path); free(m); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index bb3bcbd7d..960499af2 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -290,7 +290,8 @@ bool scr_ni_stub_reconcile(ScrDyn *stub, ScrDyn *real) { } /* A stub whose Node value is needed: starting Node materializes every - * deferred module (scr_node_island_boot.c), after which the ref is real. + * deferred module (scr_node_island_boot.c), or in per-module mode the + * boot unit evaluates the stub's component; then the ref is real. * False with the module's load error pending. */ ScrDyn *scr_ni_lazy_subclass_new(ScrDyn *const *args) { ScrNodeRef *ref = calloc(1, sizeof *ref); @@ -343,6 +344,9 @@ static bool nh_live(ScrNodeRef *ref) { if (ref->stub == NULL) return true; void *stub = ref->stub; scr_ni_bridge(); + /* Per-module mode (NI-7b): Node starting evaluated nothing; the stub's + * own component evaluates now. */ + if (ref->stub != NULL) scr_ni_boot_materialize(stub); if (ref->stub == NULL) return true; scr_ni_boot_stub_failed(stub); return false; @@ -923,8 +927,9 @@ ScrDyn *scr_ni_get_str(ScrDyn *obj, const char *key, size_t len) { if (nh_lazy_is(obj) && len == 4 && memcmp(key, "name", 4) == 0) return scr_dyn_retain(((ScrNodeRef *)obj->v.handle.ptr)->lazy[1]); void *stub = scr_ni_stub_of(obj); - if (stub != NULL && scr_ni_bridge_loaded() == NULL) { - /* Before Node starts, the interface snapshot answers what it holds. */ + if (stub != NULL && scr_ni_boot_snapshot_valid(stub)) { + /* Until its module evaluates, the interface snapshot answers what it + * holds. */ ScrDyn *known = scr_ni_boot_member(stub, key, len); if (known != NULL) return known; } @@ -1080,7 +1085,7 @@ int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor) { * native value crossing into Node is never an instance of an ordinary * island class (the snapshot says whether the class is one). */ void *stub = scr_ni_stub_of(ctor); - if (stub != NULL && scr_ni_bridge_loaded() == NULL && !scr_ni_is_handle(value) && + if (stub != NULL && scr_ni_boot_snapshot_valid(stub) && !scr_ni_is_handle(value) && scr_ni_boot_plain_class(stub)) return 0; /* A lazy subclass has no instances yet, and a native value is never one diff --git a/packages/runtime/src/tools/scr_node_island_probe.c b/packages/runtime/src/tools/scr_node_island_probe.c index 6354a0c86..559d8d0c0 100644 --- a/packages/runtime/src/tools/scr_node_island_probe.c +++ b/packages/runtime/src/tools/scr_node_island_probe.c @@ -3,12 +3,12 @@ * once per bridge ABI and runs it against the libnode pack the program * will load, so the interface snapshot comes from that exact Node. * - * scr-node-probe + * scr-node-probe * * It starts Node from the pack's bridge with the bootstrap (which defines * the dispatcher and the probe), makes one synchronous call with the input - * JSON, prints the outcome ("R" or "T") on stdout and stops - * Node. Never includes a Node or V8 header. */ + * JSON, writes the outcome ("R" or "T") to the output file + * (the probed modules may print) and exits. Never includes a Node or V8 header. */ #include "scr_node_bridge.h" #include @@ -142,8 +142,8 @@ static const ScrNodeBridgeHost kHost = { }; int main(int argc, char **argv) { - if (argc != 4) { - fprintf(stderr, "usage: scr-node-probe \n"); + if (argc != 5) { + fprintf(stderr, "usage: scr-node-probe \n"); return 2; } char path[4096]; @@ -174,8 +174,12 @@ int main(int argc, char **argv) { } size_t len = 0; char *outcome = bridge->call(0, 1, "", "", "probe", input, ilen, &len); - fwrite(outcome, 1, len, stdout); fflush(stdout); + FILE *out = fopen(argv[4], "wb"); + if (out == NULL || fwrite(outcome, 1, len, out) != len || fclose(out) != 0) { + fprintf(stderr, "scr-node-probe: cannot write %s\n", argv[4]); + _Exit(1); + } bridge->release(outcome); /* No orderly stop: the probe's modules may hold handles; the process * exits here. */ diff --git a/tests/corpus/6843-node-island-deferred-per-module/main.ts b/tests/corpus/6843-node-island-deferred-per-module/main.ts new file mode 100644 index 000000000..a5c10bfb3 --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/main.ts @@ -0,0 +1,12 @@ +// @requires-libnode +// Two deferred island packages that share no files (scope-B NI-7b): every +// static island import is deferred and none changes builtin state, so +// each package evaluates on its own first use, not when Node starts. +import { hello, NAME } from "pkg-a"; +import { Counter, make } from "pkg-b"; + +console.log("start", NAME, typeof hello, typeof Counter, Counter.name); +console.log(hello("native"), hello("again")); +console.log("pkg-b untouched so far:", typeof make); +const c = make(); +console.log(c.next(), c.next(), c instanceof Counter); diff --git a/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.d.ts b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.d.ts new file mode 100644 index 000000000..a512dae76 --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.d.ts @@ -0,0 +1,2 @@ +export declare function hello(who: string): string; +export declare const NAME: string; diff --git a/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.js b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.js new file mode 100644 index 000000000..3cc58bd5f --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/index.js @@ -0,0 +1,8 @@ +"use strict"; +let greeted = 0; +function hello(who) { + greeted += 1; + return `hello ${who} (${greeted})`; +} +const NAME = "pkg-a"; +module.exports = { hello, NAME }; diff --git a/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/package.json b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/package.json new file mode 100644 index 000000000..e0270f674 --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-a/package.json @@ -0,0 +1 @@ +{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.d.ts b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.d.ts new file mode 100644 index 000000000..9da03b6cc --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.d.ts @@ -0,0 +1,5 @@ +export declare class Counter { + n: number; + next(): number; +} +export declare function make(): Counter; diff --git a/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.js b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.js new file mode 100644 index 000000000..029ca753a --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/index.js @@ -0,0 +1,14 @@ +"use strict"; +class Counter { + constructor() { + this.n = 0; + } + next() { + this.n += 1; + return this.n; + } +} +function make() { + return new Counter(); +} +module.exports = { Counter, make }; diff --git a/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/package.json b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/package.json new file mode 100644 index 000000000..539831790 --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/node_modules/pkg-b/package.json @@ -0,0 +1 @@ +{ "name": "pkg-b", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6843-node-island-deferred-per-module/package.json b/tests/corpus/6843-node-island-deferred-per-module/package.json new file mode 100644 index 000000000..ee7d20a75 --- /dev/null +++ b/tests/corpus/6843-node-island-deferred-per-module/package.json @@ -0,0 +1 @@ +{ "name": "island-deferred-per-module", "private": true, "type": "module", "scriptc": { "packages": { "pkg-a": "node", "pkg-b": "node" } } } diff --git a/tests/corpus/6844-node-island-deferred-env/main.ts b/tests/corpus/6844-node-island-deferred-env/main.ts new file mode 100644 index 000000000..cb02e8597 --- /dev/null +++ b/tests/corpus/6844-node-island-deferred-env/main.ts @@ -0,0 +1,11 @@ +// @requires-libnode +// A deferred island package that reads an environment variable when it +// evaluates (scope-B NI-7b): the build records the key as a boot guard, +// and the late evaluation sees the value the variable had at the import +// position, even though the program changed it before the first use. +import { currentMode } from "env-pkg"; + +console.log("before", process.env.SCRIPTC_NI7B_MODE ?? "(unset)"); +process.env.SCRIPTC_NI7B_MODE = "changed"; +console.log("mode", currentMode()); +console.log("after", process.env.SCRIPTC_NI7B_MODE); diff --git a/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.d.ts b/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.d.ts new file mode 100644 index 000000000..9fd7e1c8c --- /dev/null +++ b/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.d.ts @@ -0,0 +1 @@ +export declare function currentMode(): string; diff --git a/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.js b/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.js new file mode 100644 index 000000000..f2de7095c --- /dev/null +++ b/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/index.js @@ -0,0 +1,7 @@ +"use strict"; +// Read once, when the module evaluates. +const mode = process.env.SCRIPTC_NI7B_MODE || "default"; +function currentMode() { + return mode; +} +module.exports = { currentMode }; diff --git a/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/package.json b/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/package.json new file mode 100644 index 000000000..05c1ee1d1 --- /dev/null +++ b/tests/corpus/6844-node-island-deferred-env/node_modules/env-pkg/package.json @@ -0,0 +1 @@ +{ "name": "env-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6844-node-island-deferred-env/package.json b/tests/corpus/6844-node-island-deferred-env/package.json new file mode 100644 index 000000000..d6751fc83 --- /dev/null +++ b/tests/corpus/6844-node-island-deferred-env/package.json @@ -0,0 +1 @@ +{ "name": "island-deferred-env", "private": true, "type": "module", "scriptc": { "packages": { "env-pkg": "node" } } } diff --git a/tests/corpus/6845-node-island-deferred-require/main.ts b/tests/corpus/6845-node-island-deferred-require/main.ts new file mode 100644 index 000000000..9a185ada9 --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/main.ts @@ -0,0 +1,16 @@ +// @requires-libnode +// A native package's top-level require() of a Node island package is +// deferred like a static import (scope-B NI-7d): the island's load (which +// sets and clears a timer, leaving nothing behind) waits for the first +// call, and Node does not start at boot. +import { existsSync, readFileSync } from "node:fs"; +import { calls, quadruple } from "native-wrapper"; + +function libnodeMapped(): boolean { + if (!existsSync("/proc/self/maps")) return false; + return readFileSync("/proc/self/maps", "utf8").includes("libnode"); +} + +console.log("before first use, libnode mapped:", libnodeMapped()); +console.log("quadruple", quadruple(3), quadruple(5)); +console.log("calls", calls()); diff --git a/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.d.ts b/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.d.ts new file mode 100644 index 000000000..d2079967c --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.d.ts @@ -0,0 +1,2 @@ +export declare function quadruple(n: number): number; +export declare function calls(): number; diff --git a/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.js b/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.js new file mode 100644 index 000000000..21c71d3d0 --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/index.js @@ -0,0 +1,14 @@ +"use strict"; +// A native package that requires an island at its top level, as +// async-retry requires retry. +const timers = require("timer-pkg"); + +function quadruple(n) { + return timers.twice(timers.twice(n)); +} + +function calls() { + return timers.count(); +} + +module.exports = { quadruple, calls }; diff --git a/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/package.json b/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/package.json new file mode 100644 index 000000000..9100150e9 --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/node_modules/native-wrapper/package.json @@ -0,0 +1 @@ +{ "name": "native-wrapper", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.d.ts b/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.d.ts new file mode 100644 index 000000000..c762be5bd --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.d.ts @@ -0,0 +1,2 @@ +export declare function twice(n: number): number; +export declare function count(): number; diff --git a/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.js b/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.js new file mode 100644 index 000000000..88e279826 --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/index.js @@ -0,0 +1,18 @@ +"use strict"; +// Load-time timer housekeeping that leaves nothing behind: a clear of +// nothing, and a timer set and cleared again (smol-toml-like). +clearTimeout(undefined); +const probe = setTimeout(() => { + throw new Error("never runs"); +}, 1000); +clearTimeout(probe); + +let calls = 0; +function twice(n) { + calls += 1; + return n * 2; +} +function count() { + return calls; +} +module.exports = { twice, count }; diff --git a/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/package.json b/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/package.json new file mode 100644 index 000000000..6aacd04f5 --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/node_modules/timer-pkg/package.json @@ -0,0 +1 @@ +{ "name": "timer-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6845-node-island-deferred-require/package.json b/tests/corpus/6845-node-island-deferred-require/package.json new file mode 100644 index 000000000..b7bc84af0 --- /dev/null +++ b/tests/corpus/6845-node-island-deferred-require/package.json @@ -0,0 +1 @@ +{ "name": "island-deferred-require", "private": true, "type": "module", "scriptc": { "packages": { "timer-pkg": "node" } } } diff --git a/tests/corpus/6846-node-island-deferred-own-files/main.ts b/tests/corpus/6846-node-island-deferred-own-files/main.ts new file mode 100644 index 000000000..831a639e7 --- /dev/null +++ b/tests/corpus/6846-node-island-deferred-own-files/main.ts @@ -0,0 +1,14 @@ +// @requires-libnode +// A Node island package that reads its own files when it loads (scope-B +// NI-7e): reads inside its package are guarded inputs, so the load defers +// and the first call sees the same contents. +import { existsSync, readFileSync } from "node:fs"; +import { greet } from "data-pkg"; + +function libnodeMapped(): boolean { + if (!existsSync("/proc/self/maps")) return false; + return readFileSync("/proc/self/maps", "utf8").includes("libnode"); +} + +console.log("before first use, libnode mapped:", libnodeMapped()); +console.log(greet("native")); diff --git a/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/data.txt b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/data.txt new file mode 100644 index 000000000..b888317a3 --- /dev/null +++ b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/data.txt @@ -0,0 +1 @@ +hello from data diff --git a/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.d.ts b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.d.ts new file mode 100644 index 000000000..9141f57ce --- /dev/null +++ b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.d.ts @@ -0,0 +1 @@ +export declare function greet(who: string): string; diff --git a/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.js b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.js new file mode 100644 index 000000000..729dd7938 --- /dev/null +++ b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/index.js @@ -0,0 +1,14 @@ +"use strict"; +// Reads its own package's files at load: guarded inputs (the build records +// their size and mtime as boot guards), so the load still defers. +const fs = require("fs"); +const path = require("path"); + +const dataFile = path.join(__dirname, "data.txt"); +const greeting = fs.existsSync(dataFile) ? fs.readFileSync(dataFile, "utf8").trim() : "missing"; +const version = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")).version; + +function greet(who) { + return `${greeting}, ${who} (v${version})`; +} +module.exports = { greet }; diff --git a/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/package.json b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/package.json new file mode 100644 index 000000000..c08fc59d0 --- /dev/null +++ b/tests/corpus/6846-node-island-deferred-own-files/node_modules/data-pkg/package.json @@ -0,0 +1 @@ +{ "name": "data-pkg", "version": "2.3.4", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6846-node-island-deferred-own-files/package.json b/tests/corpus/6846-node-island-deferred-own-files/package.json new file mode 100644 index 000000000..cbd8a4f67 --- /dev/null +++ b/tests/corpus/6846-node-island-deferred-own-files/package.json @@ -0,0 +1 @@ +{ "name": "island-deferred-own-files", "private": true, "type": "module", "scriptc": { "packages": { "data-pkg": "node" } } } diff --git a/tests/corpus/6847-node-island-punycode-warning/legacy.cjs b/tests/corpus/6847-node-island-punycode-warning/legacy.cjs new file mode 100644 index 000000000..c8aef4746 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/legacy.cjs @@ -0,0 +1,4 @@ +"use strict"; +// Island code outside node_modules that loads punycode on first use: Node +// prints its DEP0040 deprecation warning at that call. +exports.toAscii = (domain) => require("punycode").toASCII(domain); diff --git a/tests/corpus/6847-node-island-punycode-warning/main.ts b/tests/corpus/6847-node-island-punycode-warning/main.ts new file mode 100644 index 000000000..093ee4e2d --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/main.ts @@ -0,0 +1,18 @@ +// @requires-libnode +// @normalize-pid +// Node's deprecation warnings from island code (scope-B NI-7f): +// - punycode required first by island code outside node_modules: DEP0040 +// on stderr, with Node's text, at that call; +// - punycode required by an island package (inside node_modules, as +// node-fetch's whatwg-url): no warning, in Node as here; +// - a package that warns when it loads: not deferred (the probe records +// the warning), so it warns at its import position. +import { requireModule } from "@scriptc/node"; +import { toAscii } from "puny-pkg"; +import { ok } from "warn-pkg"; + +console.log("start", ok()); +const legacy = requireModule("./legacy.cjs"); +const ascii: string = legacy.toAscii("bücher.de"); +console.log("legacy", ascii); +console.log("package", toAscii("mañana.com")); diff --git a/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.d.ts b/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.d.ts new file mode 100644 index 000000000..eeb9a16c7 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.d.ts @@ -0,0 +1 @@ +export declare function toAscii(domain: string): string; diff --git a/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.js b/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.js new file mode 100644 index 000000000..61174ecf1 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/index.js @@ -0,0 +1,8 @@ +"use strict"; +// An island package that uses the deprecated punycode builtin, as +// node-fetch's whatwg-url does (here on first use). A require from inside +// node_modules does not warn. +function toAscii(domain) { + return require("punycode").toASCII(domain); +} +module.exports = { toAscii }; diff --git a/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/package.json b/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/package.json new file mode 100644 index 000000000..3146d3ea6 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/node_modules/puny-pkg/package.json @@ -0,0 +1 @@ +{ "name": "puny-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.d.ts b/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.d.ts new file mode 100644 index 000000000..5ac877b56 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.d.ts @@ -0,0 +1 @@ +export declare function ok(): string; diff --git a/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.js b/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.js new file mode 100644 index 000000000..f65bb862c --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/index.js @@ -0,0 +1,6 @@ +"use strict"; +// An island package that emits a deprecation warning when it loads: the +// build's probe records the warning, so the package is not deferred and the +// warning appears where Node prints it. +process.emitWarning("warn-pkg's default export is deprecated", "DeprecationWarning", "DEP_WARN_PKG"); +exports.ok = () => "warn-pkg ok"; diff --git a/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/package.json b/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/package.json new file mode 100644 index 000000000..8c7e6f0e4 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/node_modules/warn-pkg/package.json @@ -0,0 +1 @@ +{ "name": "warn-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6847-node-island-punycode-warning/package.json b/tests/corpus/6847-node-island-punycode-warning/package.json new file mode 100644 index 000000000..31d5492e0 --- /dev/null +++ b/tests/corpus/6847-node-island-punycode-warning/package.json @@ -0,0 +1 @@ +{ "name": "island-punycode-warning", "private": true, "type": "module", "scriptc": { "packages": { "puny-pkg": "node", "warn-pkg": "node" } } } diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index f4c5c6202..00989972b 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -156,6 +156,18 @@ function wantsNoDeprecation(file: string): boolean { return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l)); } +/** `// @normalize-pid` in the directive head: Node's process warnings + * ("(node:) [DEP0040] ...") are compared with the pid replaced on both + * sides — for island programs, where Node itself prints the warning in the + * compiled program's process. */ +function wantsPidNormalized(file: string): boolean { + return directiveHead(file).some((l) => /^\/\/ @normalize-pid\s*$/.test(l)); +} + +function withoutWarningPid(stderr: Buffer): Buffer { + return Buffer.from(stderr.toString("utf8").replace(/^\(node:\d+\) /gm, "(node:PID) "), "utf8"); +} + /** The Node oracle's argv for a corpus entry (shims + the per-program * transform flag). */ /** `// @disallow-code-generation` in the directive head: the Node oracle @@ -426,9 +438,13 @@ describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized // — their stderr carries the uncaught report, whose format is a // documented divergence. if (expectedExit === 0) { - const nativeErr = comparableStderr(nativeRes.stderr); - if (!nodeRes.stderr.equals(nativeErr)) { - expect(nativeErr.toString("utf8")).toBe(nodeRes.stderr.toString("utf8")); + const pidFree = wantsPidNormalized(file); + const nativeErr = pidFree + ? withoutWarningPid(comparableStderr(nativeRes.stderr)) + : comparableStderr(nativeRes.stderr); + const nodeErr = pidFree ? withoutWarningPid(nodeRes.stderr) : nodeRes.stderr; + if (!nodeErr.equals(nativeErr)) { + expect(nativeErr.toString("utf8")).toBe(nodeErr.toString("utf8")); expect.unreachable("stderr differed at byte level but not after utf8 decode"); } } diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index 8b266c083..f5cd3a170 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -146,6 +146,13 @@ function nodeOracleFile(file: string): string { return path; } +/** `// @normalize-pid` (differential.test.ts): Node's process warnings + * compare with the pid replaced on both sides. */ +function pidFree(file: string, stderr: Buffer): Buffer { + if (!directiveHead(file).some((l) => /^\/\/ @normalize-pid\s*$/.test(l))) return stderr; + return Buffer.from(stderr.toString("utf8").replace(/^\(node:\d+\) /gm, "(node:PID) "), "utf8"); +} + function wantsNoDeprecation(file: string): boolean { return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l)); } @@ -213,7 +220,9 @@ describe(`llvm differential corpus (${files.length} programs, ${optimizationMode const actual = await runBinary(result.binaryPath, []); expect(actual.stdout, `${optimization} stdout`).toEqual(oracle.stdout); if (expectedExit === 0) - expect(comparableStderr(actual.stderr), `${optimization} stderr`).toEqual(oracle.stderr); + expect(pidFree(file, comparableStderr(actual.stderr)), `${optimization} stderr`).toEqual( + pidFree(file, oracle.stderr), + ); expect(actual.exitCode, `${optimization} exit status`).toBe(expectedExit); } }, From 00e88c2dfd1d5f402fcc29845a80e3f85eadcc7b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:56 -0700 Subject: [PATCH 002/193] Islands: CommonJS island requires, builtin-class handles, JavaScript widened bindings Node islands stack, part 10 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/cjs-inline-require.ts | 4 + .../lowering/expressions/identifiers.ts | 12 ++ .../frontend/lowering/lower-node-island.ts | 163 ++++++++++++++++-- .../src/frontend/lowering/lower-stmts.ts | 24 ++- .../compiler/src/frontend/lowering/lowerer.ts | 49 ++++++ packages/compiler/src/frontend/pipeline.ts | 38 +++- packages/compiler/src/frontend/tsc-codes.ts | 10 ++ .../6880-node-island-cjs-entry/main.js.json | 40 +++++ .../6881-js-let-widened-assignment.js.json | 6 + .../main.js.json | 6 + .../main.ts.json | 6 + .../main.js.json | 6 + .../corpus/readable-stream-3/main.cjs.json | 9 - .../readable-stream-disable/main.cjs.json | 36 ---- .../corpus/6880-node-island-cjs-entry/main.js | 30 ++++ .../node_modules/envkit/index.js | 7 + .../node_modules/envkit/package.json | 1 + .../node_modules/startkit/index.js | 8 + .../node_modules/startkit/package.json | 1 + .../6880-node-island-cjs-entry/package.json | 1 + .../corpus/6881-js-let-widened-assignment.js | 34 ++++ .../6882-node-island-nested-require/main.js | 26 +++ .../node_modules/lazykit/index.js | 5 + .../node_modules/lazykit/package.json | 1 + .../package.json | 1 + .../6883-node-island-builtin-handles/main.ts | 21 +++ .../node_modules/urlkit/index.d.ts | 8 + .../node_modules/urlkit/index.js | 17 ++ .../node_modules/urlkit/package.json | 1 + .../package.json | 1 + .../6884-js-readonly-declared-write/env.d.ts | 7 + .../6884-js-readonly-declared-write/main.js | 9 + .../6880-node-island-cjs-entry/main.js.json | 1 + .../6881-js-let-widened-assignment.js.json | 1 + .../main.js.json | 1 + .../main.ts.json | 1 + .../main.js.json | 1 + 37 files changed, 535 insertions(+), 58 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6880-node-island-cjs-entry/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6881-js-let-widened-assignment.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6883-node-island-builtin-handles/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6884-js-readonly-declared-write/main.js.json create mode 100644 tests/corpus/6880-node-island-cjs-entry/main.js create mode 100644 tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/index.js create mode 100644 tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/package.json create mode 100644 tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/index.js create mode 100644 tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/package.json create mode 100644 tests/corpus/6880-node-island-cjs-entry/package.json create mode 100644 tests/corpus/6881-js-let-widened-assignment.js create mode 100644 tests/corpus/6882-node-island-nested-require/main.js create mode 100644 tests/corpus/6882-node-island-nested-require/node_modules/lazykit/index.js create mode 100644 tests/corpus/6882-node-island-nested-require/node_modules/lazykit/package.json create mode 100644 tests/corpus/6882-node-island-nested-require/package.json create mode 100644 tests/corpus/6883-node-island-builtin-handles/main.ts create mode 100644 tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.d.ts create mode 100644 tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.js create mode 100644 tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/package.json create mode 100644 tests/corpus/6883-node-island-builtin-handles/package.json create mode 100644 tests/corpus/6884-js-readonly-declared-write/env.d.ts create mode 100644 tests/corpus/6884-js-readonly-declared-write/main.js create mode 100644 tests/harness/corpus-costs/6880-node-island-cjs-entry/main.js.json create mode 100644 tests/harness/corpus-costs/6881-js-let-widened-assignment.js.json create mode 100644 tests/harness/corpus-costs/6882-node-island-nested-require/main.js.json create mode 100644 tests/harness/corpus-costs/6883-node-island-builtin-handles/main.ts.json create mode 100644 tests/harness/corpus-costs/6884-js-readonly-declared-write/main.js.json diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts index a4df8d910..8aae56337 100644 --- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -36,6 +36,7 @@ import { isCjsRequireGlobal } from "./lower-node-module.js"; import { DYN, type IrExpr } from "../../ir/ir.js"; import { nodeThrowExpr } from "../../ir/build.js"; import { probeNodeRequireRefusal } from "../npm.js"; +import { lowerIslandRequireCall } from "./lower-node-island.js"; export interface InlineRequire { call: ts.CallExpression; @@ -216,6 +217,9 @@ export function lowerUncompiledRequireCall( ); } if (canonicalBuiltinModule(spec) !== null) return null; + // A package that runs in a Node island loads at the call (scope B). + const island = lowerIslandRequireCall(lowerer, call); + if (island !== null) return island; if (!spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#")) { const refusal = probeNodeRequireRefusal(sf.fileName, spec); if (refusal !== null) { diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index e51b49459..178ee7132 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -82,6 +82,15 @@ import { mixinFnOfCallee } from "../lower-mixins.js"; import { isNavigatorGlobal, refuseNavigator } from "../builtins/navigator.js"; import { wasiGuestPath } from "../../../wasi-paths.js"; +/** A JavaScript binding with checked-dynamic storage because the program + * stores values outside its declared type (analyzeJsWidenedBindings): the + * checker's narrowing at a read does not describe the stored value. */ +function isJsWidenedBinding(lowerer: Lowerer, expr: ts.Identifier): boolean { + if (lowerer.jsWidenedBindings.size === 0) return false; + const symbol = lowerer.resolveValueSymbol(expr); + return symbol !== null && symbol !== undefined && lowerer.jsWidenedBindings.has(symbol); +} + export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: SrcLoc): IrExpr { if (lowerer.isStdlibGlobal(expr, "console")) { return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }; @@ -266,6 +275,8 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: } return { kind: "varRef", localId: local.id, type: local.type, loc }; } + if (local.type.kind === "dyn" && isJsWidenedBinding(lowerer, expr)) + return { kind: "varRef", localId: local.id, type: local.type, loc }; const narrowed = lowerer.maybeNarrow( { kind: "varRef", localId: local.id, type: local.type, loc }, expr, @@ -369,6 +380,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: if (use?.kind === "call") return runtimeOptionalReceiverRead(lowerer, expr, g, null, loc); return ref; } + if (g.type.kind === "dyn" && isJsWidenedBinding(lowerer, expr)) return ref; return lowerer.maybeNarrow(ref, expr); } // `undefined` — tsc's intrinsic global (a local shadowing the name diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 6e80d991a..5afbdc568 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -16,7 +16,7 @@ import { readFileSync, realpathSync } from "node:fs"; import { resolveFrom } from "../../node-islands/resolve.js"; import { dirname, join } from "node:path"; import * as ts from "../ts7/adapter.js"; -import { locOf } from "../program.js"; +import { isJsSourceFile, locOf } from "../program.js"; import type { NodeApiMember } from "../node-api-import.js"; import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js"; import { @@ -458,6 +458,17 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const name = decl.propertyName?.text ?? decl.name.text; binding = name === "default" ? "default" : { name }; } else { + // `const { name } = require("pkg")`: a member of module.exports, read + // at each use (shortcut: not copied once at the declaration). + const property = requiredPropertyOf(decl); + if (property !== null && loadsAtStatement(property.declaration)) { + const spec = requireInitSpecOf(property.declaration); + if (spec === null) return null; + const file = ts.getSourceFileOfNode(decl).fileName; + const path = islandModuleFile(islands, file, spec, "require"); + const module = path === null ? undefined : lowerer.islandModules.get(path); + return module === undefined ? null : { kind: "named", module, name: property.name }; + } // `const x = require("pkg")` (a CommonJS module of a native package // requiring an island package): x is module.exports. const spec = requireSpecOf(decl); @@ -634,8 +645,41 @@ function undeclaredHandleType(lowerer: Lowerer, type: ts.Type): boolean { return containerType(lowerer, type); } +/** Builtin classes whose native representation is not a JavaScript object + * (URLSearchParams, Headers, Request, …): an island instance of one has no + * native counterpart to convert into, so it stays a handle. */ +const BUILTIN_HANDLE_CLASSES: ReadonlySet = new Set([ + "URL", + "URLSearchParams", + "Headers", + "Request", + "Response", + "FormData", + "Blob", + "File", + "AbortController", + "AbortSignal", + "ReadableStream", + "WritableStream", + "TransformStream", + "TextEncoder", + "TextDecoder", +]); + +/** Whether `type` is one of BUILTIN_HANDLE_CLASSES as declared by the + * standard library or @types/node (not a program class of the same name). */ +function builtinHandleClass(type: ts.Type): boolean { + const symbol = type.getSymbol(); + if (symbol === undefined || !BUILTIN_HANDLE_CLASSES.has(String(symbol.escapedName))) return false; + return ( + symbol.declarations.length > 0 && + symbol.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile) + ); +} + /** Arrays, tuples, Maps and Sets (every arm of a union, nullish arms - * aside): the object types an island hands back without declaring them. */ + * aside), and the builtin classes of builtinHandleClass: the object types + * an island hands back without declaring them. */ function containerType(lowerer: Lowerer, type: ts.Type): boolean { if (type.isUnionType()) return ts @@ -648,7 +692,7 @@ function containerType(lowerer: Lowerer, type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Object) === 0) return false; if (lowerer.checker.isArrayType(type) || lowerer.checker.isTupleType(type)) return true; const name = type.getSymbol()?.escapedName; - return name === "Map" || name === "Set"; + return name === "Map" || name === "Set" || builtinHandleClass(type); } /** A member read, call, index or `new` whose receiver or callee is an @@ -1490,6 +1534,20 @@ function islandReceiver( return { value: lowerer.coerceToExpected(lowerer.lowerExpr(receiver), FOREIGN) }; } +/** Whether `require(spec)` in `file` loads an island module. */ +export function isIslandRequireSpec(lowerer: Lowerer, file: string, spec: string): boolean { + const islands = lowerer.nodeIslands; + return islands !== null && islandModuleFile(islands, file, spec, "require") !== null; +} + +/** `require("")` in a CommonJS module, at a call no other + * lowering claimed (inside a function, or as a subexpression): the island + * module loads at the call. Null when the package is not an island. */ +export function lowerIslandRequireCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + if (lowerer.nodeIslands === null) return null; + return lowerIslandLoadCall(lowerer, call, locOf(call)) ?? null; +} + /** `require("pkg")` (the global require, anywhere) and `import("pkg")` of * an island module: load it at the call. Undefined: not such a call. */ function lowerIslandLoadCall( @@ -1837,12 +1895,84 @@ function plainLiteral(node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpressi /** The specifier of `x = require("spec")` (a variable declaration binding * an identifier to a literal require), or null. */ function requireSpecOf(decl: ts.Node): string | null { - if ( - !ts.isVariableDeclaration(decl) || - !ts.isIdentifier(decl.name) || - decl.initializer === undefined - ) - return null; + if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null; + return requireInitSpecOf(decl); +} + +/** The property a destructured CommonJS require binds: `name` in + * `const { name } = require("spec")` or `const { name: local } = …`, with + * the declaration. Rest elements, defaults and nested patterns are null. */ +function requiredPropertyOf( + decl: ts.Node, +): { name: string; declaration: ts.VariableDeclaration } | null { + if (!ts.isBindingElement(decl) || decl.dotDotDotToken || decl.initializer) return null; + if (!ts.isIdentifier(decl.name)) return null; + const pattern = decl.parent; + if (pattern === undefined || !ts.isObjectBindingPattern(pattern)) return null; + const declaration = pattern.parent; + if (declaration === undefined || !ts.isVariableDeclaration(declaration)) return null; + const key = decl.propertyName; + const name = + key === undefined + ? decl.name.text + : ts.isIdentifier(key) || ts.isStringLiteral(key) + ? key.text + : null; + return name === null ? null : { name, declaration }; +} + +/** Whether every element of `const { … } = require("")` binds a + * plain member (requiredPropertyOf): only then do the names read the + * module global; other patterns keep the ordinary lowering. */ +function islandPatternBindable(pattern: ts.ObjectBindingPattern): boolean { + return pattern.elements.nodes.every((e) => requiredPropertyOf(e) !== null); +} + +/** Whether the top-level declaration `decl` of a JavaScript module loads + * its island module at the statement (lowerIslandRequireAtStatement) + * rather than in the module's %init prelude: `const`/`let` declarations + * binding an identifier or a plain object pattern. Reads before the + * statement are TDZ errors in Node, so the binding is never observed + * unassigned. `var` keeps the prelude load (shortcut: a hoisted `var` read + * before its statement sees the module, where Node sees undefined). */ +function loadsAtStatement(decl: ts.VariableDeclaration): boolean { + const list = decl.parent; + if (list === undefined || !ts.isVariableDeclarationList(list)) return false; + if ((list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) === 0) return false; + if (!isJsSourceFile(ts.getSourceFileOfNode(decl))) return false; + if (ts.isIdentifier(decl.name)) return true; + return ts.isObjectBindingPattern(decl.name) && islandPatternBindable(decl.name); +} + +/** The top-level statement `const x = require("")` or `const { a, + * b: c } = require("")` of a JavaScript module: assigns the island + * module (deferred like any top-level require) to its module global at the + * statement's position, so writes before it (`process.env.NODE_ENV = …`) + * are visible to the module's evaluation, as in Node; `x` is the module, + * `a` and `c` read its members at each use (shortcut: not copied once at + * the declaration). Null for any other declaration. */ +export function lowerIslandRequireAtStatement( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): IrStmt | null { + const islands = lowerer.nodeIslands; + if (islands === null || !loadsAtStatement(decl)) return null; + const spec = requireInitSpecOf(decl); + if (spec === null) return null; + const path = islandModuleFile(islands, ts.getSourceFileOfNode(decl).fileName, spec, "require"); + const module = path === null ? undefined : lowerer.islandModules.get(path); + if (path === null || module === undefined) return null; + const loc = locOf(decl); + const load = runsAtModuleEvaluation(decl) + ? deferIslandModule(path, loc) + : loadIslandModule(path, loc); + return { kind: "assign", localId: module.global.id, value: load, loc }; +} + +/** The specifier of a variable declaration initialized with a literal + * `require("spec")`, whatever its binding name or pattern, or null. */ +function requireInitSpecOf(decl: ts.VariableDeclaration): string | null { + if (decl.initializer === undefined) return null; let init: ts.Expression = decl.initializer; while (ts.isParenthesizedExpression(init)) init = init.expression; if ( @@ -1859,7 +1989,8 @@ function requireSpecOf(decl: ts.Node): string | null { /** Top-level `const x = require("pkg")` statements of island packages in a * compiled module (a native package requiring an island package): load the * island module in the module's %init prelude (shortcut: before the body, - * not at the statement), deferred exactly like a static import (scope-B + * not at the statement; JavaScript `const`/`let` declarations load at the + * statement instead, loadsAtStatement), deferred exactly like a static import (scope-B * NI-7d: the build decides from the probe, stubs stand in until first use). */ export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void { const islands = lowerer.nodeIslands; @@ -1867,6 +1998,18 @@ export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void for (const stmt of sf.statements.nodes) { if (!ts.isVariableStatement(stmt)) continue; for (const decl of stmt.declarationList.declarations.nodes) { + // JavaScript `const`/`let` requires: the module global exists from + // here on; the statement itself loads the module at its position + // (lowerIslandRequireAtStatement). + if (loadsAtStatement(decl)) { + const spec = requireInitSpecOf(decl); + const path = spec === null ? null : islandModuleFile(islands, sf.fileName, spec, "require"); + if (path !== null) { + lowerer.usesNodeIsland = true; + islandModuleGlobal(lowerer, path); + } + continue; + } const spec = requireSpecOf(decl); if (spec === null) continue; const path = islandModuleFile(islands, sf.fileName, spec, "require"); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 438e5f998..3e9d967d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,5 +1,11 @@ import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js"; -import { islandHandleBinding, lowerIslandExpr, lowerIslandSubclass } from "./lower-node-island.js"; +import { + islandHandleBinding, + lowerIslandExpr, + isIslandRequireSpec, + lowerIslandRequireAtStatement, + lowerIslandSubclass, +} from "./lower-node-island.js"; import { isIslandSubclassDecl } from "../../node-islands/boundary.js"; import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js"; import { hostsAll } from "./expressions/hosted-narrowing.js"; @@ -1868,6 +1874,10 @@ function lowerVarDeclarator( const spec = requireSpecOf(decl.initializer)!; if (lowerer.externalTypes.has(spec)) lowerer.externalHostFence(spec, decl, false); if (ts.isSourceFile(stmt.parent)) { + // `const x = require("")` and `const { name } = …`: the + // island module loads here, at the statement (lower-node-island.ts). + const island = lowerIslandRequireAtStatement(lowerer, decl); + if (island !== null) return [island]; const init = lowerer.requireInitStmt(spec, decl); const out: IrStmt[] = init ? [init] : []; // Nested patterns destructure the named export's value right after @@ -1892,6 +1902,12 @@ function lowerVarDeclarator( // same routing top-level requires use, and no statement remains. // Relative requires keep the fence: their module-init-at-position // semantics needs storage this model doesn't represent. + // A nested require of an island package is an ordinary binding of the + // island module, loaded at the statement (lower-node-island.ts). + if (isIslandRequireSpec(lowerer, ts.getSourceFileOfNode(decl).fileName, spec)) { + const lowered = lowerer.lowerVarDecl(decl, isLet); + return lowered ? [lowered] : []; + } if (canonicalBuiltinModule(spec) !== null) { return canonicalBuiltinModule(spec) === "punycode" ? [lowerer.punycodeRequireWarning(decl)] @@ -5451,6 +5467,12 @@ export function lowerVarDecl( // Promise.prototype is an ordinary object the checker types Promise. if (!decl.type && !hasJsTypeAnnotation(decl) && isPromisePrototypeRead(lowerer, decl.initializer)) type = DYN; + // A JavaScript binding assigned values outside its declared type + // (analyzeJsWidenedBindings in lowerer.ts) holds them as checked-dynamic. + { + const symbol = lowerer.resolveValueSymbol(decl.name); + if (symbol && lowerer.jsWidenedBindings.has(symbol)) type = DYN; + } // A typed-arguments function expression (typed-arguments.ts): the // checker types the binding variadic, but its value has the fixed // signature every use asserts. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7d97131e5..6a14b6167 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2353,6 +2353,10 @@ export class Lowerer { * prepass. This includes explicit annotations: unchecked TS annotations do * not prove that an array property read produced a value. */ readonly runtimeOptionalBindingTypes = new Map(); + /** JavaScript bindings assigned values outside their declared type + * (analyzeJsWidenedBindings): checked-dynamic storage, and reads keep the + * stored value instead of narrowing it to the checker's type. */ + readonly jsWidenedBindings = new Set(); /** A pipeline() function stage's `source` parameter: the IR type of what * the pipeline passes it (the previous stage as an async iterable), set * while the stage lowers. Parameter typing and `for await` heads read @@ -5036,6 +5040,49 @@ export class Lowerer { } } + /** A JavaScript `let`/`var` binding assigned a value its declared type + * does not admit (`let t = parseInt(s); if (isNaN(t)) t = undefined`): + * TypeScript types the binding from its initializer and reports the + * assignment (TS2322), which JavaScript files relax, so the binding takes + * checked-dynamic storage and holds whatever the program stores. JSDoc + * type annotations keep their declared storage. */ + analyzeJsWidenedBindings(parts: FileParts[]): void { + for (const { sf } of parts) { + if (!isJsSourceFileName(sf.fileName) || sf.isDeclarationFile) continue; + ts.walkPreorder(sf, (node) => { + if ( + !ts.isBinaryExpression(node) || + node.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(node.left) + ) + return undefined; + const symbol = this.resolveValueSymbol(node.left); + if (!symbol || this.runtimeOptionalBindingTypes.has(symbol)) return undefined; + const decl = symbol.valueDeclaration; + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !isJsSourceFileName(ts.getSourceFileOfNode(decl).fileName) || + hasJsTypeAnnotation(decl) || + decl.parent === undefined || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ) + return undefined; + const declared = this.checker.getTypeOfSymbol(symbol); + if (declared.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return undefined; + const assigned = this.checker.getTypeAtLocation(node.right); + if (this.checker.isTypeAssignableTo(assigned, declared)) return undefined; + this.runtimeOptionalBindingTypes.set(symbol, DYN); + this.jsWidenedBindings.add(symbol); + const global = this.globalsBySymbol.get(symbol); + if (global) global.type = DYN; + return undefined; + }); + } + } + analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void { const provenance = RuntimeOptionalProvenance.fromEnvironment(); // The site whose value the current promotion follows (debug only). @@ -7517,6 +7564,7 @@ export class Lowerer { this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); this.analyzeGuardedRecordReadGlobals(parts); + this.analyzeJsWidenedBindings(parts); }); // Decorated classes analyze AFTER the whole collection pass: a // decorator's return type may name a subclass declared below the @@ -7947,6 +7995,7 @@ export class Lowerer { this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); this.analyzeGuardedRecordReadGlobals(parts); + this.analyzeJsWidenedBindings(parts); }); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index d6f4bc866..1037c554e 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -230,7 +230,7 @@ function detectAutoPackages( // without the declaration eligibility bar. const owner = npmStaticPackageOfPath(sf.fileName); const viaTransitive = owner !== null && transitive.has(owner); - const edges: { spec: string; loc: SrcLoc }[] = []; + const edges: { spec: string; loc: SrcLoc; nested?: true }[] = []; for (const stmt of sf.statements.nodes) { if ( (isImportDeclaration(stmt) || isExportDeclaration(stmt)) && @@ -248,6 +248,33 @@ function detectAutoPackages( for (const req of requiresOf(stmt)) edges.push({ spec: req.spec, loc: locOf(req.node) }); } } + // A program file's literal require() calls outside top-level require + // statements (inside functions, in expressions) name packages the + // program loads at run time too. They count only to refuse a package + // that ships no declarations, which then runs in a Node island; any + // other package keeps the top-level edge rule. + if ( + mode === "lib" && + !viaTransitive && + owner === null && + !sf.fileName.includes("/node_modules/") && + sf.text.includes("require(") + ) { + const topLevel = new Set(edges.map((e) => `${e.spec}@${e.loc.start}`)); + for (const call of moduleSourceCandidates(load.program, sf).calls) { + const arg = call.arguments.nodes[0]; + if ( + arg === undefined || + !isStringLiteralLike(arg) || + !isIdentifier(call.expression) || + call.expression.text !== "require" + ) + continue; + const loc = locOf(call); + if (topLevel.has(`${arg.text}@${loc.start}`)) continue; + edges.push({ spec: arg.text, loc, nested: true }); + } + } // Transitive selection counts every literal require() and import() // anywhere in the package, nested ones included. if (viaTransitive) { @@ -262,7 +289,7 @@ function detectAutoPackages( edges.push({ spec: arg.text, loc: locOf(call) }); } } - for (const { spec, loc } of edges) { + for (const { spec, loc, nested } of edges) { if (load.externalTypes?.has(spec)) continue; // scriptc's own packages are not npm dependencies: contract types are // declarations only, and @scriptc/threads and @scriptc/experimental @@ -304,6 +331,13 @@ function detectAutoPackages( ? resolveBareModule(sf.fileName, spec, "js-only") : null; const runtimePkg = js !== null && js.packageName !== npm?.packageName ? js.packageName : null; + // Nested requires only refuse packages without own declarations. + if ( + nested === true && + !(npm === null && runtimePkg !== null) && + !(npm !== null && !/\.d\.(ts|mts|cts)$/.test(npm.typesFile)) + ) + continue; if (npm === null || runtimePkg !== null) { if (mode !== "lib" || runtimePkg === null || judged!.has(runtimePkg)) continue; judged!.add(runtimePkg); diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index e6c0be7cd..8b5593965 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -132,6 +132,16 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // binding types as any and reached uses fence instead of passing unchecked 2307, 2792, + // a write to a property declarations mark readonly (2540): `readonly` is + // a type annotation that Node never sees (Next.js's types mark + // process.env.NODE_ENV readonly; its server.js assigns it); the write + // lowers like any other and keeps its runtime behavior + 2540, + // a resolved JavaScript module without declarations (7016): Node loads + // it like any other module; deployment trees such as Next.js standalone + // output ship runtime files only, so the binding types as any and its + // uses take the dynamic paths, as for 2307 + 7016, // useUnknownInCatchVariables' annotation demand ("'e' is of type // 'unknown'", "Object is of type 'unknown'"): a JS catch clause CANNOT // annotate its variable, so the demand is unsatisfiable in source — diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6880-node-island-cjs-entry/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6880-node-island-cjs-entry/main.js.json new file mode 100644 index 000000000..742b059eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6880-node-island-cjs-entry/main.js.json @@ -0,0 +1,40 @@ +{ + "order": [ + "/tests/corpus/6880-node-island-cjs-entry/main.js" + ], + "diags": [ + { + "code": "SC1010", + "message": "the 'envkit' module is not supported yet", + "loc": { + "file": "/tests/corpus/6880-node-island-cjs-entry/main.js", + "start": 628, + "end": 645 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + }, + { + "code": "SC1010", + "message": "the 'envkit' module is not supported yet", + "loc": { + "file": "/tests/corpus/6880-node-island-cjs-entry/main.js", + "start": 652, + "end": 678 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + }, + { + "code": "SC1010", + "message": "the 'startkit' module is not supported yet", + "loc": { + "file": "/tests/corpus/6880-node-island-cjs-entry/main.js", + "start": 685, + "end": 745 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6881-js-let-widened-assignment.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6881-js-let-widened-assignment.js.json new file mode 100644 index 000000000..6cb9cf64c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6881-js-let-widened-assignment.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6881-js-let-widened-assignment.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json new file mode 100644 index 000000000..9010bb753 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6882-node-island-nested-require/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6883-node-island-builtin-handles/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6883-node-island-builtin-handles/main.ts.json new file mode 100644 index 000000000..6134b1607 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6883-node-island-builtin-handles/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6883-node-island-builtin-handles/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6884-js-readonly-declared-write/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6884-js-readonly-declared-write/main.js.json new file mode 100644 index 000000000..7d6ede918 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6884-js-readonly-declared-write/main.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6884-js-readonly-declared-write/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-3/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-3/main.cjs.json index 8a308528a..8b86f8fa3 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-3/main.cjs.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-3/main.cjs.json @@ -3,15 +3,6 @@ "/tests/corpus/readable-stream-3/main.cjs" ], "diags": [ - { - "code": "SC0001", - "message": "Could not find a declaration file for module 'readable-stream'. '/tests/corpus/readable-stream-3/node_modules/readable-stream/readable.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/readable-stream` if it exists or add a new declaration (.d.ts) file containing `declare module 'readable-stream';`", - "loc": { - "file": "/tests/corpus/readable-stream-3/main.cjs", - "start": 554, - "end": 571 - } - }, { "code": "SC1010", "message": "the 'readable-stream' module is not supported yet", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-disable/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-disable/main.cjs.json index 21f85bd01..fd6a08082 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-disable/main.cjs.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readable-stream-disable/main.cjs.json @@ -3,42 +3,6 @@ "/tests/corpus/readable-stream-disable/main.cjs" ], "diags": [ - { - "code": "SC0001", - "message": "Could not find a declaration file for module 'readable-stream'. '/tests/corpus/readable-stream-disable/node_modules/readable-stream/readable.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/readable-stream` if it exists or add a new declaration (.d.ts) file containing `declare module 'readable-stream';`", - "loc": { - "file": "/tests/corpus/readable-stream-disable/main.cjs", - "start": 581, - "end": 598 - } - }, - { - "code": "SC0001", - "message": "Could not find a declaration file for module 'readable-stream/transform'. '/tests/corpus/readable-stream-disable/node_modules/readable-stream/transform.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/readable-stream` if it exists or add a new declaration (.d.ts) file containing `declare module 'readable-stream/transform';`", - "loc": { - "file": "/tests/corpus/readable-stream-disable/main.cjs", - "start": 625, - "end": 652 - } - }, - { - "code": "SC0001", - "message": "Could not find a declaration file for module 'readable-stream/passthrough'. '/tests/corpus/readable-stream-disable/node_modules/readable-stream/passthrough.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/readable-stream` if it exists or add a new declaration (.d.ts) file containing `declare module 'readable-stream/passthrough';`", - "loc": { - "file": "/tests/corpus/readable-stream-disable/main.cjs", - "start": 681, - "end": 710 - } - }, - { - "code": "SC0001", - "message": "Could not find a declaration file for module 'readable-stream/writable'. '/tests/corpus/readable-stream-disable/node_modules/readable-stream/writable.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/readable-stream` if it exists or add a new declaration (.d.ts) file containing `declare module 'readable-stream/writable';`", - "loc": { - "file": "/tests/corpus/readable-stream-disable/main.cjs", - "start": 736, - "end": 762 - } - }, { "code": "SC1010", "message": "the 'readable-stream' module is not supported yet", diff --git a/tests/corpus/6880-node-island-cjs-entry/main.js b/tests/corpus/6880-node-island-cjs-entry/main.js new file mode 100644 index 000000000..9479adf3a --- /dev/null +++ b/tests/corpus/6880-node-island-cjs-entry/main.js @@ -0,0 +1,30 @@ +// @requires-libnode +// The shape of a Next.js standalone server.js: a CommonJS entry that writes +// process.env, then requires packages that ship no declarations (TS7016 in +// a JavaScript file is relaxed), destructures one of them, and calls into +// it. The packages run in Node islands. Each require loads its island module +// at the statement, so the environment writes before it are visible to the +// module's evaluation, as in Node. +const path = require('path') + +console.log('entry start') +process.env.ISLAND_MODE = 'production' +process.env.ISLAND_CONFIG = JSON.stringify({ name: path.basename('/srv/app'), port: 3000 }) + +require('envkit') +const envkit = require('envkit') +const { startServer, version: startVersion } = require('startkit') + +console.log(envkit.seen()) +console.log('loads', envkit.loads()) +console.log('startkit', startVersion, typeof startServer) + +const config = JSON.parse(process.env.ISLAND_CONFIG) +startServer({ name: config.name, port: config.port }) + .then((message) => { + console.log(message) + return startServer({ name: 'bad', port: -1 }) + }) + .catch((err) => { + console.log('caught', err.message) + }) diff --git a/tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/index.js b/tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/index.js new file mode 100644 index 000000000..7a1b63a45 --- /dev/null +++ b/tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/index.js @@ -0,0 +1,7 @@ +// Ships no declarations, like the traced node_modules of a Next.js +// standalone build. Records the environment its evaluation saw. +const seenMode = process.env.ISLAND_MODE; +const seenConfig = process.env.ISLAND_CONFIG; +globalThis.__envkitLoads = (globalThis.__envkitLoads || 0) + 1; +exports.seen = () => `envkit saw ISLAND_MODE=${seenMode}, config=${seenConfig}`; +exports.loads = () => globalThis.__envkitLoads; diff --git a/tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/package.json b/tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/package.json new file mode 100644 index 000000000..ced6f1d42 --- /dev/null +++ b/tests/corpus/6880-node-island-cjs-entry/node_modules/envkit/package.json @@ -0,0 +1 @@ +{ "name": "envkit", "version": "1.0.0", "main": "index.js" } diff --git a/tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/index.js b/tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/index.js new file mode 100644 index 000000000..d4aad0ee7 --- /dev/null +++ b/tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/index.js @@ -0,0 +1,8 @@ +// Its evaluation has effects (a timer), so the island loads eagerly. +const seenMode = process.env.ISLAND_MODE; +setTimeout(() => {}, 0); +exports.startServer = async function startServer(opts) { + if (opts.port < 0) throw new Error(`bad port ${opts.port}`); + return `started ${opts.name} on ${opts.port} (startkit saw ISLAND_MODE=${seenMode}, now ${process.env.ISLAND_MODE})`; +}; +exports.version = "1.0.0"; diff --git a/tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/package.json b/tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/package.json new file mode 100644 index 000000000..225e32010 --- /dev/null +++ b/tests/corpus/6880-node-island-cjs-entry/node_modules/startkit/package.json @@ -0,0 +1 @@ +{ "name": "startkit", "version": "1.0.0", "main": "index.js" } diff --git a/tests/corpus/6880-node-island-cjs-entry/package.json b/tests/corpus/6880-node-island-cjs-entry/package.json new file mode 100644 index 000000000..3508deaae --- /dev/null +++ b/tests/corpus/6880-node-island-cjs-entry/package.json @@ -0,0 +1 @@ +{ "name": "island-cjs-entry", "private": true } diff --git a/tests/corpus/6881-js-let-widened-assignment.js b/tests/corpus/6881-js-let-widened-assignment.js new file mode 100644 index 000000000..22f711c6d --- /dev/null +++ b/tests/corpus/6881-js-let-widened-assignment.js @@ -0,0 +1,34 @@ +// A JavaScript binding whose later assignments store values its +// initializer's type does not admit (TS2322, relaxed in JavaScript files): +// the binding holds whatever the program stores, as in Node. Next.js's +// standalone server.js does this with KEEP_ALIVE_TIMEOUT. +let keepAliveTimeout = parseInt(process.env.SCRIPTC_UNSET_TIMEOUT, 10) +if (Number.isNaN(keepAliveTimeout) || !Number.isFinite(keepAliveTimeout) || keepAliveTimeout < 0) { + keepAliveTimeout = undefined +} +console.log(keepAliveTimeout, typeof keepAliveTimeout) +const opts = { keepAliveTimeout } +console.log(JSON.stringify(opts), 'keepAliveTimeout' in opts, opts.keepAliveTimeout === undefined) + +let label = 'none' +function relabel(n) { + label = n +} +relabel(42) +console.log(label, typeof label) + +function show(raw) { + let value = Number(raw) + if (Number.isNaN(value)) value = null + console.log('value', value, value === null) +} +show('12') +show('x') + +var counter = 0 +counter = counter + 1 +console.log('counter', counter) + +let port = parseInt('8080', 10) +if (port > 65535) port = undefined +console.log('port', port, typeof port) diff --git a/tests/corpus/6882-node-island-nested-require/main.js b/tests/corpus/6882-node-island-nested-require/main.js new file mode 100644 index 000000000..a396a119d --- /dev/null +++ b/tests/corpus/6882-node-island-nested-require/main.js @@ -0,0 +1,26 @@ +// @requires-libnode +// require() of a Node island package inside functions of a CommonJS +// entry: the module loads at the first call that requires it (after the +// environment write before that call), later calls get the same module. +'use strict' + +function viaExpression(name) { + return require('lazykit').greet(name) +} + +function viaBinding(name) { + const lazykit = require('lazykit') + return `${lazykit.greet(name)} (${lazykit.loads()} load)` +} + +const lazy = { + get kit() { + return require('lazykit') + }, +} + +console.log('start') +process.env.ISLAND_PHASE = 'after-start' +console.log(viaExpression('first')) +console.log(viaBinding('second')) +console.log(lazy.kit.greet('getter'), lazy.kit === require('lazykit')) diff --git a/tests/corpus/6882-node-island-nested-require/node_modules/lazykit/index.js b/tests/corpus/6882-node-island-nested-require/node_modules/lazykit/index.js new file mode 100644 index 000000000..f12f60063 --- /dev/null +++ b/tests/corpus/6882-node-island-nested-require/node_modules/lazykit/index.js @@ -0,0 +1,5 @@ +// Ships no declarations. Counts its evaluations: Node evaluates it once. +globalThis.__lazykitLoads = (globalThis.__lazykitLoads || 0) + 1; +console.log('lazykit evaluated, ISLAND_PHASE =', process.env.ISLAND_PHASE); +exports.greet = (name) => `hello ${name}`; +exports.loads = () => globalThis.__lazykitLoads; diff --git a/tests/corpus/6882-node-island-nested-require/node_modules/lazykit/package.json b/tests/corpus/6882-node-island-nested-require/node_modules/lazykit/package.json new file mode 100644 index 000000000..61f0c2d13 --- /dev/null +++ b/tests/corpus/6882-node-island-nested-require/node_modules/lazykit/package.json @@ -0,0 +1 @@ +{ "name": "lazykit", "version": "1.0.0", "main": "index.js" } diff --git a/tests/corpus/6882-node-island-nested-require/package.json b/tests/corpus/6882-node-island-nested-require/package.json new file mode 100644 index 000000000..9884b3575 --- /dev/null +++ b/tests/corpus/6882-node-island-nested-require/package.json @@ -0,0 +1 @@ +{ "name": "island-nested-require", "private": true } diff --git a/tests/corpus/6883-node-island-builtin-handles/main.ts b/tests/corpus/6883-node-island-builtin-handles/main.ts new file mode 100644 index 000000000..b6b8f7d09 --- /dev/null +++ b/tests/corpus/6883-node-island-builtin-handles/main.ts @@ -0,0 +1,21 @@ +// @requires-libnode +// Island values typed with builtin classes (URLSearchParams, Headers, URL) +// stay island handles: their members and methods run in the island, and +// passing them back hands the island its own object. +import { RequestLike, describeParams, makeParams } from "urlkit"; + +const request = new RequestLike("http://localhost/api/products?tag=software&limit=2", { + "x-trace": "abc", +}); +const tag = request.searchParams.get("tag") ?? "none"; +const missing = request.searchParams.get("missing") ?? "none"; +console.log("tag", tag, "missing", missing, "has limit", request.searchParams.has("limit")); +console.log("pathname", request.url.pathname, "host", request.url.host); +console.log("trace", request.headers.get("x-trace"), "absent", request.headers.get("x-absent")); + +const params = request.searchParams; +params.append("page", "3"); +console.log("described", describeParams(params)); +console.log("round trip", describeParams(makeParams("a=1&b=two"))); +console.log("same object", params === request.searchParams); +console.log("size", params.size); diff --git a/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.d.ts b/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.d.ts new file mode 100644 index 000000000..3bde17834 --- /dev/null +++ b/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.d.ts @@ -0,0 +1,8 @@ +export declare class RequestLike { + constructor(url: string, headers?: Record); + readonly url: URL; + readonly headers: Headers; + get searchParams(): URLSearchParams; +} +export declare function describeParams(params: URLSearchParams): string; +export declare function makeParams(query: string): URLSearchParams; diff --git a/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.js b/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.js new file mode 100644 index 000000000..86c82fdc6 --- /dev/null +++ b/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/index.js @@ -0,0 +1,17 @@ +// An island class whose members are builtin class instances (the shape of +// Next.js's NextRequest.nextUrl.searchParams). +export class RequestLike { + constructor(url, headers = {}) { + this.url = new URL(url); + this.headers = new Headers(headers); + } + get searchParams() { + return this.url.searchParams; + } +} +export function describeParams(params) { + return [...params.entries()].map(([k, v]) => `${k}=${v}`).join("&"); +} +export function makeParams(query) { + return new URLSearchParams(query); +} diff --git a/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/package.json b/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/package.json new file mode 100644 index 000000000..f33dc20f7 --- /dev/null +++ b/tests/corpus/6883-node-island-builtin-handles/node_modules/urlkit/package.json @@ -0,0 +1 @@ +{ "name": "urlkit", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6883-node-island-builtin-handles/package.json b/tests/corpus/6883-node-island-builtin-handles/package.json new file mode 100644 index 000000000..b9f4a1e9b --- /dev/null +++ b/tests/corpus/6883-node-island-builtin-handles/package.json @@ -0,0 +1 @@ +{ "name": "island-builtin-handles", "private": true, "type": "module", "scriptc": { "packages": { "urlkit": "node" } } } diff --git a/tests/corpus/6884-js-readonly-declared-write/env.d.ts b/tests/corpus/6884-js-readonly-declared-write/env.d.ts new file mode 100644 index 000000000..fc125e54e --- /dev/null +++ b/tests/corpus/6884-js-readonly-declared-write/env.d.ts @@ -0,0 +1,7 @@ +// Declarations that mark a member readonly, as Next.js's global types do +// for process.env.NODE_ENV. +declare namespace NodeJS { + interface ProcessEnv { + readonly APP_STAGE?: "development" | "production"; + } +} diff --git a/tests/corpus/6884-js-readonly-declared-write/main.js b/tests/corpus/6884-js-readonly-declared-write/main.js new file mode 100644 index 000000000..48d7783fc --- /dev/null +++ b/tests/corpus/6884-js-readonly-declared-write/main.js @@ -0,0 +1,9 @@ +/// +// A JavaScript entry writes a member its declarations mark readonly +// (TS2540, relaxed in JavaScript files): `readonly` is a type annotation, +// and the write behaves as it does in Node. +console.log('before', process.env.APP_STAGE) +process.env.APP_STAGE = 'production' +console.log('after', process.env.APP_STAGE, typeof process.env.APP_STAGE) +delete process.env.APP_STAGE +console.log('deleted', process.env.APP_STAGE) diff --git a/tests/harness/corpus-costs/6880-node-island-cjs-entry/main.js.json b/tests/harness/corpus-costs/6880-node-island-cjs-entry/main.js.json new file mode 100644 index 000000000..8affc810d --- /dev/null +++ b/tests/harness/corpus-costs/6880-node-island-cjs-entry/main.js.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 23000 } diff --git a/tests/harness/corpus-costs/6881-js-let-widened-assignment.js.json b/tests/harness/corpus-costs/6881-js-let-widened-assignment.js.json new file mode 100644 index 000000000..2cfcaed98 --- /dev/null +++ b/tests/harness/corpus-costs/6881-js-let-widened-assignment.js.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 5400 } diff --git a/tests/harness/corpus-costs/6882-node-island-nested-require/main.js.json b/tests/harness/corpus-costs/6882-node-island-nested-require/main.js.json new file mode 100644 index 000000000..8affc810d --- /dev/null +++ b/tests/harness/corpus-costs/6882-node-island-nested-require/main.js.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 23000 } diff --git a/tests/harness/corpus-costs/6883-node-island-builtin-handles/main.ts.json b/tests/harness/corpus-costs/6883-node-island-builtin-handles/main.ts.json new file mode 100644 index 000000000..8affc810d --- /dev/null +++ b/tests/harness/corpus-costs/6883-node-island-builtin-handles/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 23000 } diff --git a/tests/harness/corpus-costs/6884-js-readonly-declared-write/main.js.json b/tests/harness/corpus-costs/6884-js-readonly-declared-write/main.js.json new file mode 100644 index 000000000..2cfcaed98 --- /dev/null +++ b/tests/harness/corpus-costs/6884-js-readonly-declared-write/main.js.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 5400 } From d646fa70d07efafff20ed76328a0acf9099ab606 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:56 -0700 Subject: [PATCH 003/193] Collect garbage cycles across the scriptc and V8 heaps (NI-8) Node islands stack, part 11 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/native/executable.ts | 1 + packages/compiler/src/node-islands/libnode.ts | 2 +- .../6890-node-island-gc-emitter/main.mjs.json | 6 + .../6891-node-island-gc-closure/main.mjs.json | 6 + .../main.ts.json | 6 + .../main.mjs.json | 7 + .../main.mjs.json | 6 + .../6895-node-island-gc-weakmap/main.mjs.json | 6 + .../main.mjs.json | 6 + .../libnode-common/bridge/scr_node_bridge.cc | 557 +++++++++++++++++- .../runtime-pack-matrix.mjs | 1 + packages/runtime/src/scr_async.c | 11 + packages/runtime/src/scr_node_bridge.h | 51 +- packages/runtime/src/scr_node_island.c | 21 + packages/runtime/src/scr_node_island.h | 11 + packages/runtime/src/scr_node_island_export.c | 10 +- packages/runtime/src/scr_node_island_gc.c | 529 +++++++++++++++++ packages/runtime/src/scr_node_island_handle.c | 54 +- .../6890-node-island-gc-emitter/lib.cjs | 34 ++ .../6890-node-island-gc-emitter/main.mjs | 43 ++ .../6891-node-island-gc-closure/lib.cjs | 22 + .../6891-node-island-gc-closure/main.mjs | 34 ++ .../main.ts | 35 ++ .../node_modules/channel-pkg/index.d.ts | 9 + .../node_modules/channel-pkg/index.js | 35 ++ .../node_modules/channel-pkg/package.json | 1 + .../package.json | 1 + .../main.mjs | 20 + .../peers.mjs | 30 + .../sessions.mjs | 20 + .../6894-node-island-gc-promises/lib.cjs | 38 ++ .../6894-node-island-gc-promises/main.mjs | 40 ++ .../6895-node-island-gc-weakmap/lib.cjs | 32 + .../6895-node-island-gc-weakmap/main.mjs | 30 + .../lib.cjs | 9 + .../main.mjs | 40 ++ .../fixtures/node-island/gc-cycles-peers.mjs | 23 + .../node-island/gc-cycles-sessions.mjs | 18 + tests/fixtures/node-island/gc-cycles.cjs | 69 +++ tests/fixtures/node-island/gc-cycles.mjs | 105 ++++ .../6890-node-island-gc-emitter/main.mjs.json | 1 + .../6891-node-island-gc-closure/main.mjs.json | 1 + .../main.ts.json | 1 + .../main.mjs.json | 1 + .../main.mjs.json | 1 + .../6895-node-island-gc-weakmap/main.mjs.json | 1 + .../main.mjs.json | 1 + tests/harness/node-island-gc.test.ts | 98 +++ tests/harness/node-island-handles.test.ts | 6 +- tests/harness/node-island.test.ts | 2 +- 50 files changed, 2052 insertions(+), 40 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json create mode 100644 packages/runtime/src/scr_node_island_gc.c create mode 100644 tests/corpus/6890-node-island-gc-emitter/lib.cjs create mode 100644 tests/corpus/6890-node-island-gc-emitter/main.mjs create mode 100644 tests/corpus/6891-node-island-gc-closure/lib.cjs create mode 100644 tests/corpus/6891-node-island-gc-closure/main.mjs create mode 100644 tests/corpus/6892-node-island-gc-native-subclass/main.ts create mode 100644 tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts create mode 100644 tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js create mode 100644 tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json create mode 100644 tests/corpus/6892-node-island-gc-native-subclass/package.json create mode 100644 tests/corpus/6893-node-island-gc-shared-module/main.mjs create mode 100644 tests/corpus/6893-node-island-gc-shared-module/peers.mjs create mode 100644 tests/corpus/6893-node-island-gc-shared-module/sessions.mjs create mode 100644 tests/corpus/6894-node-island-gc-promises/lib.cjs create mode 100644 tests/corpus/6894-node-island-gc-promises/main.mjs create mode 100644 tests/corpus/6895-node-island-gc-weakmap/lib.cjs create mode 100644 tests/corpus/6895-node-island-gc-weakmap/main.mjs create mode 100644 tests/corpus/6896-node-island-native-method-member/lib.cjs create mode 100644 tests/corpus/6896-node-island-native-method-member/main.mjs create mode 100644 tests/fixtures/node-island/gc-cycles-peers.mjs create mode 100644 tests/fixtures/node-island/gc-cycles-sessions.mjs create mode 100644 tests/fixtures/node-island/gc-cycles.cjs create mode 100644 tests/fixtures/node-island/gc-cycles.mjs create mode 100644 tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json create mode 100644 tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json create mode 100644 tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json create mode 100644 tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json create mode 100644 tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json create mode 100644 tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json create mode 100644 tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json create mode 100644 tests/harness/node-island-gc.test.ts diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 4d12ef5ad..250d82ec1 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -343,6 +343,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ "scr_node_island_handle.c", "scr_node_island_export.c", "scr_node_island_boot.c", + "scr_node_island_gc.c", ), ]; diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index d0b46c08c..d4d2e7ece 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -23,7 +23,7 @@ import { findInstalledLibnodePack, hostLibnodeTarget, libnodePackPackage } from export const NODE_ISLAND_MIN_NODE = "24.13.1"; /** The bridge ABI this compiler's runtime speaks (scr_node_bridge.h). */ -export const NODE_BRIDGE_ABI = 9; +export const NODE_BRIDGE_ABI = 10; /** The prefix of a recorded path relative to the executable's directory. */ export const ORIGIN = "$ORIGIN/"; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json new file mode 100644 index 000000000..1f981c390 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6890-node-island-gc-emitter/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json new file mode 100644 index 000000000..7caba2f5e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6891-node-island-gc-closure/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json new file mode 100644 index 000000000..240e978be --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6892-node-island-gc-native-subclass/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json new file mode 100644 index 000000000..2e9648874 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs", + "/tests/corpus/6893-node-island-gc-shared-module/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json new file mode 100644 index 000000000..a36bcaae1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6894-node-island-gc-promises/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json new file mode 100644 index 000000000..a40319e41 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6895-node-island-gc-weakmap/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json new file mode 100644 index 000000000..e9f6e08f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6896-node-island-native-method-member/main.mjs" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index d63a3b6f1..5dd58f664 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -20,21 +20,40 @@ // Calls into JavaScript therefore do not drain queues themselves: the // runtime's checkpoints do, through `checkpoint`, and Node's own after // every callback it dispatches. +// +// One heap, as far as cycles go (scope-B NI-8). The import table holds V8 +// objects as v8::TracedReferences that Node's CppHeap traces, and every +// export representative carries a CppHeap object that traces what its +// native value reaches across the boundary; see "cross-heap cycle +// collection" below. #include "scr_node_bridge.h" +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include +#include +#include +#include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -60,6 +79,9 @@ const ScrNodeBridgeHost *g_host = nullptr; void DropAllHandles(); void DropAllExports(); void FlushReleasedExports(); +bool StartCollector(v8::Isolate *isolate); +void StopCollector(v8::Isolate *isolate); +void Touch(uint32_t export_id); std::unique_ptr g_platform; node::EmbedderSnapshotData::Pointer g_snapshot; std::unique_ptr g_setup; @@ -254,6 +276,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar g_stack_limit = stack_limit; node::SetProcessExitHandler(g_setup->env(), [](node::Environment *, int code) { g_host->exit(code); }); + if (!StartCollector(isolate)) return Copy("this libnode's isolate has no CppHeap", nullptr); HandleScope handles(isolate); Local context = g_setup->context(); Context::Scope context_scope(context); @@ -654,6 +677,7 @@ void Stop() { node::Stop(g_setup->env()); g_call.Reset(); g_load.Reset(); + StopCollector(isolate); DropAllHandles(); DropAllExports(); for (Global *fn : @@ -678,26 +702,42 @@ void Stop() { // ── object handles (scope-B NI-4) ─────────────────────────────────────── -// The import table: V8 values the runtime holds, one v8::Global per value -// and one id per value while held (found again through the value's -// identity hash), so a V8 object crossing twice is the same runtime -// handle. A typed array's slot also keeps its backing store, so memory the -// runtime aliases stays valid even if JavaScript detaches the buffer. +// The import table: V8 values the runtime holds, one entry per value and +// one id per value while held (found again through the value's identity +// hash), so a V8 object crossing twice is the same runtime handle. A typed +// array's slot also keeps its backing store, so memory the runtime aliases +// stays valid even if JavaScript detaches the buffer. +// +// An entry holds its value as a v8::TracedReference, which only the +// cross-heap collector's tracing marks (NI-8): as a root while native +// roots may use it, otherwise through the representative of an export +// whose native value reaches it. An entry nothing traced in a major GC is +// reclaimed by V8 and becomes dead: the runtime no longer reaches it and +// only drops its id, and any other use is an internal error. struct Slot { - Global value; + v8::TracedReference value; std::shared_ptr store; int hash = 0; - bool live = false; + bool live = false; // the runtime holds the id + bool dead = false; // V8 reclaimed the value: the reference is never touched again + bool rooted = true; // traced as a root under an exact analysis + std::atomic traced{0}; // the GC cycle that last traced it }; -std::vector g_slots(1); // id 0 is never used +// The GC lock guards what the collector's tracing reads, from V8's marking +// threads: the slot vector, slot references and flags, the export GC +// table, reach lists and the collector's mode. The main thread never holds +// it while V8 may allocate. +std::mutex g_gc_lock; +std::vector> g_slots(1); // index = id; id 0 is never used std::vector g_free_ids; std::unordered_multimap g_ids; uint32_t g_live_handles = 0; // Strings of out-values: valid until the next outermost entry. std::deque g_scratch; int g_entry_depth = 0; +void NoteFresh(uint32_t entry); int IdentityHash(Local v) { if (v->IsObject()) return v.As()->GetIdentityHash(); @@ -709,7 +749,7 @@ uint32_t Import(Isolate *isolate, Local v, std::shared_ptrsecond]; + Slot &slot = *g_slots[it->second]; if (slot.value.Get(isolate)->StrictEquals(v)) return it->second; } uint32_t id; @@ -718,36 +758,67 @@ uint32_t Import(Isolate *isolate, Local v, std::shared_ptr(g_slots.size()); - g_slots.emplace_back(); } - Slot &slot = g_slots[id]; - slot.value.Reset(isolate, v); - slot.store = std::move(store); - slot.hash = hash; - slot.live = true; + { + std::lock_guard lock(g_gc_lock); + if (id == g_slots.size()) g_slots.emplace_back(); + if (!g_slots[id]) g_slots[id] = std::make_unique(); + Slot &slot = *g_slots[id]; + // An assigning store: a reference made while V8 marks is marked. + slot.value.Reset(isolate, v); + slot.store = std::move(store); + slot.hash = hash; + slot.live = true; + slot.dead = false; + slot.rooted = true; + } + NoteFresh(id); g_ids.emplace(hash, id); g_live_handles++; return id; } -Local HandleValue(Isolate *isolate, uint32_t id) { - if (id == 0 || id >= g_slots.size() || !g_slots[id].live) return v8::Undefined(isolate); - return g_slots[id].value.Get(isolate); +[[noreturn]] void CollectedInUse(uint32_t id) { + std::fprintf(stderr, + "scriptc: internal error: island handle %u was used after V8 collected its " + "object (cross-heap collector)\n", + id); + std::fflush(stderr); + std::abort(); } -void Drop(uint32_t id) { - if (id == 0 || id >= g_slots.size() || !g_slots[id].live) return; - Slot &slot = g_slots[id]; - auto range = g_ids.equal_range(slot.hash); +Local HandleValue(Isolate *isolate, uint32_t id) { + if (id == 0 || id >= g_slots.size() || !g_slots[id] || !g_slots[id]->live) + return v8::Undefined(isolate); + Slot &slot = *g_slots[id]; + if (slot.dead) CollectedInUse(id); + return slot.value.Get(isolate); +} + +void ForgetIdentity(uint32_t id, int hash) { + auto range = g_ids.equal_range(hash); for (auto it = range.first; it != range.second; ++it) { if (it->second == id) { g_ids.erase(it); break; } } - slot.value.Reset(); - slot.store.reset(); - slot.live = false; +} + +void Drop(uint32_t id) { + if (id == 0 || id >= g_slots.size() || !g_slots[id] || !g_slots[id]->live) return; + Slot &slot = *g_slots[id]; + if (!slot.dead) ForgetIdentity(id, slot.hash); + std::shared_ptr store; + { + std::lock_guard lock(g_gc_lock); + // V8 already reclaimed a dead slot's node: forget it without touching it. + if (slot.dead) new (&slot.value) v8::TracedReference(); + else slot.value.Reset(); + store = std::move(slot.store); + slot.live = false; + slot.dead = false; + } g_free_ids.push_back(id); g_live_handles--; } @@ -758,6 +829,269 @@ void DropAllHandles() { uint32_t LiveHandles() { return g_live_handles; } +// ── cross-heap cycle collection (scope-B NI-8) ───────────────────────── +// V8's marker decides what lives on both sides of the boundary. Before a +// major GC marks (its prologue), the runtime analyzes the native subgraph +// under the exported values (gc_analyze, scr_node_island_gc.c): which +// exports its own roots hold, which imports they hold, and what every +// other export's native value reaches (imports and other exports). Then: +// - Roots, a CppHeap object held by a Persistent, traces the imports +// native roots hold and the representatives of the exports they hold; +// - every representative carries a CppHeap object (Reach, wrapped by a +// carrier object the representative holds under a private symbol) that +// traces what its export reaches; +// - Fresh traces references made while V8 marks (new imports, new +// exports, and exports JavaScript hands to the runtime or calls into +// while V8 marks, which become roots), retraced through a Steele +// barrier. +// V8 reclaims every traced reference this left untraced: the imports +// become dead (the runtime holds them only from garbage it frees next), +// and representatives V8 did not mark die, which releases their exports +// (export_release) so the runtime's cycle collector frees the native half. +// Without an analysis (the runtime declined, or V8 skipped the prologue) +// the collector is conservative: Roots traces everything. +// +// Node creates the isolate with its own CppHeap (NewIsolate: owned by V8) +// and supports addons wrapping CppHeap objects with v8::Object::Wrap; the +// bridge uses that heap. V8 13.6's EmbedderRootsHandler only resets +// droppable references in minor GCs, and attaching a second CppHeap is no +// longer possible, so this is the one way to put embedder edges into +// V8's major-GC marking. + +struct ExportGc { + v8::TracedReference rep; // the representative, traced only by the collector + bool rooted = true; + std::vector reach; // what the export reaches (SCR_NODE_GC_EXPORT marks exports) + std::atomic traced{0}; + uint32_t gen = 0; // tells this representative's Reach from older ones + uint8_t tenure = 0; // consecutive analyses that found it held natively +}; +std::unordered_map> g_export_gc; +uint32_t g_export_gen = 0; +enum { kConservative = 0, kExact = 1 }; +int g_gc_mode = kConservative; +uint32_t g_epoch = 1; // the GC cycle; a reference traced in it carries it +std::vector g_fresh; // entries to trace with Fresh +bool g_fresh_pending = false; // Fresh will be (re)traced and sees g_fresh +bool g_gc_trace = false; // SCRIPTC_NODE_ISLANDS_TRACE contains "gc" +bool g_gc_off = false; // SCRIPTC_NODE_ISLANDS_GC=off: every handle a root +ScrNodeGcStats g_gc_stats; +struct { + size_t exports, rooted, imports, free, reach, visited, touched; + uint64_t ns; + bool analyzed; + bool incremental; // analyzed when incremental marking started +} g_gc_cycle; +uint64_t g_cycle_freed_exports = 0; + +void TraceEntry(cppgc::Visitor *visitor, uint32_t entry, uint32_t epoch) { + auto *js = static_cast(visitor); + if (entry & SCR_NODE_GC_EXPORT) { + auto it = g_export_gc.find(entry & ~SCR_NODE_GC_EXPORT); + if (it == g_export_gc.end() || it->second->rep.IsEmptyThreadSafe()) return; + js->Trace(it->second->rep); + it->second->traced.store(epoch, std::memory_order_relaxed); + return; + } + if (entry == 0 || entry >= g_slots.size() || !g_slots[entry]) return; + Slot &slot = *g_slots[entry]; + if (!slot.live || slot.dead) return; + js->Trace(slot.value); + slot.traced.store(epoch, std::memory_order_relaxed); +} + +class Roots final : public cppgc::GarbageCollected { + public: + void Trace(cppgc::Visitor *visitor) const { + std::lock_guard lock(g_gc_lock); + bool exact = g_gc_mode == kExact; + uint32_t epoch = g_epoch; + for (uint32_t id = 1; id < g_slots.size(); id++) + if (g_slots[id] && (!exact || g_slots[id]->rooted)) TraceEntry(visitor, id, epoch); + for (auto &it : g_export_gc) + if (!exact || it.second->rooted) TraceEntry(visitor, it.first | SCR_NODE_GC_EXPORT, epoch); + } +}; + +class Fresh final : public cppgc::GarbageCollected { + public: + void Trace(cppgc::Visitor *visitor) const { + std::lock_guard lock(g_gc_lock); + for (uint32_t entry : g_fresh) TraceEntry(visitor, entry, g_epoch); + g_fresh.clear(); + g_fresh_pending = false; + } +}; + +class Reach final : public cppgc::GarbageCollected { + public: + Reach(uint32_t id, uint32_t gen) : id_(id), gen_(gen) {} + void Trace(cppgc::Visitor *visitor) const { + std::lock_guard lock(g_gc_lock); + if (g_gc_mode != kExact) return; // Roots traces everything + auto it = g_export_gc.find(id_); + if (it == g_export_gc.end() || it->second->gen != gen_) return; + for (uint32_t entry : it->second->reach) TraceEntry(visitor, entry, g_epoch); + } + + private: + uint32_t id_, gen_; +}; + +v8::CppHeap *g_cpp_heap = nullptr; +// What a representative keeps alive natively, as V8 learns of it (its +// external memory): V8 schedules its major GCs by its own heap, which the +// native halves of cross-heap garbage do not grow. An estimate per +// representative (the native value, its closure or record and what they +// hold); reported lazily, never from inside a GC. +constexpr int64_t kExportExternalBytes = 2048; +int64_t g_external_reported = 0; + +void ReportExternal(Isolate *isolate) { + int64_t want = static_cast(g_export_gc.size()) * kExportExternalBytes; + if (want == g_external_reported) return; + isolate->AdjustAmountOfExternalAllocatedMemory(want - g_external_reported); + g_external_reported = want; +} +cppgc::Persistent *g_roots = nullptr; +cppgc::Persistent *g_fresh_root = nullptr; +Global g_gc_key; +Global g_carrier_template; + +bool Marking() { + return g_cpp_heap != nullptr && cppgc::subtle::HeapState::IsMarking(g_cpp_heap->GetHeapHandle()); +} + +// Makes V8 trace `object` again if it already did in this marking. +void Retrace(const void *object) { + cppgc::subtle::HeapConsistency::WriteBarrierParams params; + if (cppgc::subtle::HeapConsistency::GetWriteBarrierType(object, params) == + cppgc::subtle::HeapConsistency::WriteBarrierType::kMarking) + cppgc::subtle::HeapConsistency::SteeleWriteBarrier(params, object); +} + +// A reference made, or an export rooted, while V8 marks. +void NoteFresh(uint32_t entry) { + if (g_fresh_root == nullptr || !Marking()) return; + bool retrace; + { + std::lock_guard lock(g_gc_lock); + g_fresh.push_back(entry); + retrace = !g_fresh_pending; + g_fresh_pending = true; + } + if (retrace) Retrace(g_fresh_root->Get()); +} + +// JavaScript handed export `export_id` to the runtime, or called into it: +// under an exact analysis the runtime may now reach its native value, so it +// becomes a root for the rest of this GC. +void Touch(uint32_t export_id) { + if (g_gc_mode != kExact) return; + auto it = g_export_gc.find(export_id); + if (it == g_export_gc.end() || it->second->rooted) return; + { + std::lock_guard lock(g_gc_lock); + it->second->rooted = true; + } + g_gc_cycle.touched++; + NoteFresh(export_id | SCR_NODE_GC_EXPORT); +} + +uint64_t NowNs() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +// Exports found stable (held natively, or reaching nothing across the +// boundary) in this many analyses in a row are tenured: analyzed again +// only at every kTenuredEvery-th analysis, roots meanwhile (a bounded cost +// for long-lived exports; their cycles are found at the next full one). +constexpr uint8_t kTenure = 2; +constexpr uint64_t kTenuredEvery = 8; + +// The analysis for this GC (in the prologue, before V8 marks or, for an +// incremental GC, when its marking starts). Newest exports first: they are +// the likeliest garbage, and the analysis may stop at its budget. +void Analyze() { + std::vector> order; // (gen, id) + order.reserve(g_export_gc.size()); + bool full = g_gc_stats.analyses % kTenuredEvery == 0; + for (auto &it : g_export_gc) + if (!it.second->rep.IsEmpty() && (full || it.second->tenure < kTenure)) + order.emplace_back(it.second->gen, it.first); + std::sort(order.begin(), order.end(), std::greater<>()); + std::vector ids; + ids.reserve(order.size()); + for (auto &o : order) ids.push_back(o.second); + ScrNodeGcResult r; + std::memset(&r, 0, sizeof r); + uint64_t t0 = NowNs(); + // A host without the analysis (the smoke tests) keeps every handle a root. + int status = g_host->gc_analyze != nullptr && !g_gc_off + ? g_host->gc_analyze(ids.data(), ids.size(), &r) + : 1; + uint64_t ns = NowNs() - t0; + g_gc_stats.analysis_ns += ns; + if (ns > g_gc_stats.max_analysis_ns) g_gc_stats.max_analysis_ns = ns; + std::lock_guard lock(g_gc_lock); + for (auto &slot : g_slots) + if (slot) slot->rooted = true; + for (auto &it : g_export_gc) { + it.second->rooted = true; + it.second->reach.clear(); + } + g_gc_cycle = {}; + g_gc_cycle.ns = ns; + if (status != 0) { + g_gc_mode = kConservative; + return; + } + g_gc_stats.analyses++; + g_gc_cycle.analyzed = true; + g_gc_cycle.exports = ids.size(); + g_gc_cycle.imports = g_live_handles; + g_gc_cycle.visited = r.visited; + for (size_t i = 0; i < r.nfree_imports; i++) { + uint32_t id = r.free_imports[i]; + if (id < g_slots.size() && g_slots[id] && g_slots[id]->live) { + g_slots[id]->rooted = false; + g_gc_cycle.free++; + } + } + for (size_t i = 0; i < ids.size(); i++) { + ExportGc &x = *g_export_gc[ids[i]]; + x.rooted = r.rooted[i] != 0; + if (x.rooted) g_gc_cycle.rooted++; + // Stable: held natively, or reaching nothing across the boundary + // (rooting such an export only delays freeing its representative). + bool stable = x.rooted || r.reach_len[i] == 0; + x.tenure = stable ? (x.tenure < 255 ? x.tenure + 1 : 255) : 0; + x.reach.assign(r.reach + r.reach_start[i], r.reach + r.reach_start[i] + r.reach_len[i]); + g_gc_cycle.reach += r.reach_len[i]; + } + g_gc_mode = kExact; +} + +void OnGcPrologue(Isolate *, v8::GCType type, v8::GCCallbackFlags, void *) { + bool marking = Marking(); + // V8 calls the prologue of an incremental GC before its marking starts, + // so nothing was traced yet: a new marking cycle, whose traced references + // carry a new epoch. + if (!marking) g_epoch++; + // The final pause of an incremental GC keeps the analysis made when its + // marking started: anything the program reached since went through Touch + // or NoteFresh. + if (type == v8::kGCTypeMarkSweepCompact && marking && g_gc_mode == kExact) return; + Analyze(); + g_gc_cycle.incremental = type == v8::kGCTypeIncrementalMarking; + if (marking && type == v8::kGCTypeIncrementalMarking && g_gc_trace) + std::fprintf(stderr, "[node-islands] gc: marking had started before the prologue\n"); +} + + + const char *Scratch(std::string text, size_t *len) { g_scratch.push_back(std::move(text)); *len = g_scratch.back().size(); @@ -902,6 +1236,13 @@ void OnExportGone(const v8::WeakCallbackInfo &info) { it->second.Reset(); g_exports.erase(it); } + { + // Nothing traced the representative: V8 reclaims its traced reference. + std::lock_guard lock(g_gc_lock); + g_export_gc.erase(id); + } + g_gc_stats.freed_exports++; + g_cycle_freed_exports++; for (auto p = g_proxy_ids.begin(); p != g_proxy_ids.end();) { if (p->second == id) p = g_proxy_ids.erase(p); else ++p; @@ -911,6 +1252,7 @@ void OnExportGone(const v8::WeakCallbackInfo &info) { // Delivers the releases V8's collector queued (never from inside a GC). void FlushReleasedExports() { + if (!g_released_exports.empty() && g_setup) ReportExternal(g_setup->isolate()); while (!g_released_exports.empty()) { uint32_t id = g_released_exports.back(); g_released_exports.pop_back(); @@ -919,8 +1261,12 @@ void FlushReleasedExports() { } } +// The export id a representative's callback carries. JavaScript is +// calling into that export: the runtime may reach its native value now. uint32_t ExportIdArg(Local data) { - return data->IsUint32() ? data.As()->Value() : 0; + uint32_t id = data->IsUint32() ? data.As()->Value() : 0; + if (id != 0) Touch(id); + return id; } // Out-value scratch of host calls made from outside any runtime entry (a @@ -1406,10 +1752,41 @@ void RefreshMirror(Isolate *isolate, Local context, uint32_t id, uint32 } } +// NI-8: the representative of export `id` holds a carrier whose CppHeap +// object traces what the export reaches, and the export's traced reference +// (marked only by the collector) starts out a root. +void AttachReach(Isolate *isolate, Local context, uint32_t id, Local rep) { + if (g_cpp_heap == nullptr) return; + if (g_carrier_template.IsEmpty()) { + Local t = v8::ObjectTemplate::New(isolate); + t->SetInternalFieldCount(1); + g_carrier_template.Reset(isolate, t); + } + if (g_gc_key.IsEmpty()) + g_gc_key.Reset(isolate, v8::Private::New(isolate, Utf8(isolate, "scriptc:gc"))); + Local carrier = g_carrier_template.Get(isolate)->NewInstance(context).ToLocalChecked(); + uint32_t gen = ++g_export_gen; + Reach *reach = cppgc::MakeGarbageCollected(g_cpp_heap->GetAllocationHandle(), id, gen); + v8::Object::Wrap(isolate, carrier, reach); + // A Proxy holds its target: an array export's private lives there. + Local holder = rep->IsProxy() ? rep.As()->GetTarget().As() : rep; + holder->SetPrivate(context, g_gc_key.Get(isolate), carrier).Check(); + { + std::lock_guard lock(g_gc_lock); + std::unique_ptr &x = g_export_gc[id]; + x = std::make_unique(); + x->rep.Reset(isolate, rep); + x->gen = gen; + } + NoteFresh(id | SCR_NODE_GC_EXPORT); + ReportExternal(isolate); +} + Local ExportValue(Isolate *isolate, Local context, const ScrNodeValue *in) { uint32_t id = in->u.exp.id; auto found = g_exports.find(id); if (found != g_exports.end()) { + Touch(id); Local existing = found->second.Get(isolate); RefreshMirror(isolate, context, id, in->flags, existing); return existing; @@ -1446,6 +1823,7 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode obj->SetInternalField(0, data); } if (!obj->IsProxy()) obj->SetPrivate(context, ExportKey(isolate), data).Check(); + AttachReach(isolate, context, id, obj); Global &slot = g_exports[id]; slot.Reset(isolate, obj); slot.SetWeak(reinterpret_cast(static_cast(id)), OnExportGone, @@ -1468,6 +1846,11 @@ void ExportSettle(uint32_t id, int rejected, const ScrNodeValue *value) { } void DropAllExports() { + { + std::lock_guard lock(g_gc_lock); + for (auto &it : g_export_gc) it.second->rep.Reset(); + g_export_gc.clear(); + } for (auto &it : g_exports) it.second.Reset(); g_exports.clear(); for (auto &it : g_resolvers) it.second.Reset(); @@ -1478,6 +1861,117 @@ void DropAllExports() { g_export_key.Reset(); } +// After a major GC: what the collector left untraced is reclaimed. Dead +// imports are forgotten (the runtime drops them from the garbage it frees +// next); a representative that lived on through JavaScript alone gets a new +// traced reference. Then the collector is conservative again until the +// next analysis. +void OnGcEpilogue(Isolate *isolate, v8::GCType, v8::GCCallbackFlags, void *) { + HandleScope handles(isolate); + uint64_t freed_imports = 0; + std::vector refresh, dead; + { + std::lock_guard lock(g_gc_lock); + uint32_t epoch = g_epoch; + for (uint32_t id = 1; id < g_slots.size(); id++) { + Slot *slot = g_slots[id].get(); + if (slot == nullptr || !slot->live || slot->dead) continue; + if (slot->traced.load(std::memory_order_relaxed) == epoch) continue; + slot->dead = true; + new (&slot->value) v8::TracedReference(); + dead.push_back(id); + freed_imports++; + } + for (auto &it : g_export_gc) { + ExportGc &x = *it.second; + if (x.traced.load(std::memory_order_relaxed) != epoch) { + new (&x.rep) v8::TracedReference(); + refresh.push_back(it.first); + } + x.rooted = true; + x.reach.clear(); + } + for (auto &slot : g_slots) + if (slot) slot->rooted = true; + g_gc_mode = kConservative; + g_fresh.clear(); + g_fresh_pending = false; + g_epoch++; + } + for (uint32_t id : dead) ForgetIdentity(id, g_slots[id]->hash); + for (uint32_t id : refresh) { + auto weak = g_exports.find(id); + std::lock_guard lock(g_gc_lock); + auto x = g_export_gc.find(id); + if (x == g_export_gc.end()) continue; + if (weak == g_exports.end() || weak->second.IsEmpty()) g_export_gc.erase(x); + else x->second->rep.Reset(isolate, weak->second.Get(isolate)); + } + g_gc_stats.major_gcs++; + g_gc_stats.freed_imports += freed_imports; + if (g_gc_trace) { + std::fflush(stdout); + if (g_gc_cycle.analyzed) + std::fprintf(stderr, + "[node-islands] gc %llu%s: exports %zu (native roots %zu, reach %zu, touched " + "%zu), imports %zu (free %zu), walked %zu in %.3f ms; freed exports %llu, " + "imports %llu\n", + (unsigned long long)g_gc_stats.major_gcs, + g_gc_cycle.incremental ? " (incremental)" : "", g_gc_cycle.exports, + g_gc_cycle.rooted, g_gc_cycle.reach, g_gc_cycle.touched, g_gc_cycle.imports, + g_gc_cycle.free, g_gc_cycle.visited, (double)g_gc_cycle.ns / 1e6, + (unsigned long long)g_cycle_freed_exports, (unsigned long long)freed_imports); + else + std::fprintf(stderr, + "[node-islands] gc %llu: not analyzed (every handle a root); freed exports " + "%llu, imports %llu\n", + (unsigned long long)g_gc_stats.major_gcs, + (unsigned long long)g_cycle_freed_exports, (unsigned long long)freed_imports); + } + g_gc_cycle = {}; + g_cycle_freed_exports = 0; +} + +void GcStats(ScrNodeGcStats *out) { + *out = g_gc_stats; + out->live_exports = static_cast(g_exports.size()); + out->live_imports = g_live_handles; +} + +bool StartCollector(Isolate *isolate) { + const char *trace = std::getenv("SCRIPTC_NODE_ISLANDS_TRACE"); + g_gc_trace = trace != nullptr && std::strstr(trace, "gc") != nullptr; + const char *mode = std::getenv("SCRIPTC_NODE_ISLANDS_GC"); + g_gc_off = mode != nullptr && std::strcmp(mode, "off") == 0; + // Node creates every isolate with a CppHeap; the import table's traced + // references need one, or V8 would reclaim them at its first major GC. + g_cpp_heap = isolate->GetCppHeap(); + if (g_cpp_heap == nullptr) return false; + g_roots = new cppgc::Persistent( + cppgc::MakeGarbageCollected(g_cpp_heap->GetAllocationHandle())); + g_fresh_root = new cppgc::Persistent( + cppgc::MakeGarbageCollected(g_cpp_heap->GetAllocationHandle())); + isolate->AddGCPrologueCallback( + OnGcPrologue, nullptr, + static_cast(v8::kGCTypeMarkSweepCompact | v8::kGCTypeIncrementalMarking)); + isolate->AddGCEpilogueCallback(OnGcEpilogue, nullptr, v8::kGCTypeMarkSweepCompact); + return true; +} + +void StopCollector(Isolate *isolate) { + if (g_roots == nullptr) return; + if (g_external_reported != 0) isolate->AdjustAmountOfExternalAllocatedMemory(-g_external_reported); + g_external_reported = 0; + isolate->RemoveGCPrologueCallback(OnGcPrologue, nullptr); + isolate->RemoveGCEpilogueCallback(OnGcEpilogue, nullptr); + delete g_roots; + delete g_fresh_root; + g_roots = nullptr; + g_fresh_root = nullptr; + g_carrier_template.Reset(); + g_gc_key.Reset(); +} + void FromV8(Isolate *isolate, Local context, Local v, ScrNodeValue *out) { std::memset(out, 0, sizeof *out); if (v.IsEmpty() || v->IsUndefined()) { out->tag = SCR_NV_UNDEFINED; return; } @@ -1514,6 +2008,7 @@ void FromV8(Isolate *isolate, Local context, Local v, ScrNodeVal } } if (uint32_t export_id = ExportIdOf(isolate, context, v)) { + Touch(export_id); out->tag = SCR_NV_EXPORT; out->u.exp.id = export_id; return; @@ -1652,7 +2147,14 @@ struct Op { Enter e; TryCatch try_catch; Op() : try_catch(e.isolate) { - if (g_entry_depth++ == 0) g_scratch.clear(); + if (g_entry_depth++ == 0) { + g_scratch.clear(); + // Representatives V8 collected since: the runtime drops their exports + // now (NI-8), whatever runs JavaScript next. The runtime holds nothing + // of theirs while it starts an operation: their values were reachable + // only through the table. + FlushReleasedExports(); + } } ~Op() { g_entry_depth--; } // The operation's outcome: 0 and the result, or 1 and the thrown value. @@ -2024,6 +2526,7 @@ const ScrNodeBridge kBridge = { LiveHandles, ExportSettle, SharedPush, + GcStats, }; } // namespace diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index c15b707d6..9e8e07ecb 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -171,6 +171,7 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_node_island_handle.c", "nodeIsland"], ["scr_node_island_export.c", "nodeIsland"], ["scr_node_island_boot.c", "nodeIsland"], + ["scr_node_island_gc.c", "nodeIsland"], ]; export function variantsFor(source) { diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 921c73648..752854fdf 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1873,6 +1873,17 @@ void scr_promise_on_settle_sync(ScrPromise *in, void (*cb)(ScrPromise *dst, ScrP *scr_promise_cb_push(in) = (struct ScrPromiseCbWaiter){ cb, scr_promise_retain(in), NULL, SCR_CB_ANY, SCR_CB_SYNC }; } + +/* Node islands (NI-8): a settled promise's dyn payload when the promise + * owns a reference to it that its trace does not visit (settled without a + * trace function), else NULL. The cross-heap analysis counts that edge. */ +void *scr_promise_untraced_dyn_payload(const ScrPromise *p) { + if ((p->payload_kind == SCR_EXC_REF || p->payload_kind == SCR_EXC_OBJ || + p->payload_kind == SCR_EXC_PRIMITIVE_REF) && + p->payload != NULL && p->trace_fn == NULL && p->release_fn == scr_dyn_release_v) + return p->payload; + return NULL; +} #endif void scr_promise_race_add(ScrPromise *race, ScrPromise *in, diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index 830b67193..4aad133f1 100644 --- a/packages/runtime/src/scr_node_bridge.h +++ b/packages/runtime/src/scr_node_bridge.h @@ -17,7 +17,7 @@ #include #include -#define SCR_NODE_BRIDGE_ABI 9u +#define SCR_NODE_BRIDGE_ABI 10u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -74,10 +74,12 @@ typedef struct ScrNodeIoState { /* ── object handles (scope-B NI-4) ──────────────────────────────────────── * A value crossing between the runtime and V8. Primitives, strings and * BigInt are copies; objects are handles: ids into the bridge's import - * table of v8::Global references, one id per V8 object while the runtime + * table of V8 references, one id per V8 object while the runtime * holds it (the bridge answers the same id for the same object), released - * with `drop`. Typed arrays and Buffers coming out of V8 also carry their - * backing store's memory (BYTES), which the runtime aliases. + * with `drop`. The table holds v8::TracedReferences that V8 marks only + * while the runtime may still use them (NI-8, gc_analyze). Typed arrays and + * Buffers coming out of V8 also carry their backing store's memory (BYTES), + * which the runtime aliases. * * Out-values the bridge writes hold bridge-owned string data valid until * the next bridge entry; the runtime copies it at once. In-values are @@ -156,6 +158,38 @@ typedef struct ScrNodeErrorInfo { size_t arrow_len; } ScrNodeErrorInfo; +/* ── cross-heap cycle collection (scope-B NI-8) ──────────────────────────── + * Before V8's major GC marks, the runtime answers which exports its own + * roots still hold and, for each other export, what its native subgraph + * reaches across the boundary (gc_analyze). V8 then marks from its roots, + * the exports native roots hold and the imports they hold, and through each + * representative it marks into what that export reaches. Exports V8 did not + * reach lose their representative (export_release), imports it did not + * reach are collected, and the runtime's cycle collector frees the native + * half. Reach entries are import ids, or export ids with SCR_NODE_GC_EXPORT + * set. All arrays are runtime-owned until the next host call. */ +#define SCR_NODE_GC_EXPORT 0x80000000u +typedef struct ScrNodeGcResult { + const uint32_t *free_imports; /* import ids no native root holds */ + size_t nfree_imports; + const uint8_t *rooted; /* per analyzed export: native roots hold it */ + const uint32_t *reach_start; /* per analyzed export: its first entry in `reach` */ + const uint32_t *reach_len; + const uint32_t *reach; + size_t visited; /* native objects the analysis walked */ +} ScrNodeGcResult; +/* What the bridge counted, for the trace and the leak tests. */ +typedef struct ScrNodeGcStats { + uint64_t major_gcs; /* V8 major GCs since Node started */ + uint64_t analyses; /* of which the runtime analyzed (the others traced everything) */ + uint64_t freed_exports; /* representatives V8 collected */ + uint64_t freed_imports; /* imports V8 collected */ + uint64_t analysis_ns; /* time spent in gc_analyze, in total */ + uint64_t max_analysis_ns; + uint32_t live_exports; /* representatives alive now */ + uint32_t live_imports; /* import-table entries alive now */ +} ScrNodeGcStats; + /* What the runtime provides to the bridge. */ typedef struct ScrNodeBridgeHost { /* An asynchronous call settled with this outcome. */ @@ -241,6 +275,11 @@ typedef struct ScrNodeBridgeHost { * of a Map, the values of a Set. */ int (*export_op)(uint32_t id, uint32_t op, const ScrNodeValue *args, size_t argc, ScrNodeValue *out); + /* NI-8: the analysis before a major GC, over the `nexports` exports that + * have a representative. 0: *out holds the answer; 1: the runtime cannot + * answer now (V8 then treats every handle as a root for this GC). Runs + * inside V8's GC prologue: it must not call into the bridge. */ + int (*gc_analyze)(const uint32_t *exports, size_t nexports, ScrNodeGcResult *out); } ScrNodeBridgeHost; typedef struct ScrNodeBridge { @@ -325,7 +364,7 @@ typedef struct ScrNodeBridge { int (*then)(const ScrNodeValue *value, uint32_t token, ScrNodeValue *out); /* The own name, message, stack and string code of an error handle. */ void (*error_info)(uint32_t h, ScrNodeErrorInfo *out); - /* Releases handle h's Global. */ + /* Releases handle h. */ void (*drop)(uint32_t h); /* The number of live import-table entries (leak tests). */ uint32_t (*live_handles)(void); @@ -334,6 +373,8 @@ typedef struct ScrNodeBridge { /* NI-6: the native value of shared export (mod, slot) changed: the * generated module's binding takes `value`. */ void (*shared_push)(uint32_t mod, uint32_t slot, const ScrNodeValue *value); + /* NI-8: the cross-heap collector's counters. */ + void (*gc_stats)(ScrNodeGcStats *out); } ScrNodeBridge; #endif /* SCR_NODE_BRIDGE_H */ diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index a178d35e3..5c4a9b3a3 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -506,6 +506,7 @@ static const ScrNodeBridgeHost ni_host = { &scr_ni_host_shared_get, &scr_ni_host_shared_tdz, &scr_ni_host_export_op, + &scr_ni_host_gc_analyze, }; typedef struct { @@ -550,9 +551,29 @@ static void ni_stop_atexit(void) { fflush(stdout); fprintf(stderr, "[node-islands] live handles %u\n", scr_ni_live_handles()); } + /* SCRIPTC_NODE_ISLANDS_TRACE=gc: the cross-heap collector's counters at + * exit (NI-8; each major GC is traced by the bridge as it ends). */ + if (trace != NULL && strstr(trace, "gc") != NULL) { + /* The native halves of cycles V8 already freed are cyclic garbage the + * exit sweep would free anyway: count what is left after it. */ + scr_collect_cycles(); + ScrNodeGcStats stats; + memset(&stats, 0, sizeof stats); + ni_bridge->gc_stats(&stats); + fflush(stdout); + fprintf(stderr, + "[node-islands] gc totals: major %llu, analyzed %llu, freed exports %llu, freed " + "imports %llu, representatives %u, import entries %u, held exports %u, held handles " + "%u, analysis max %.3f ms total %.3f ms\n", + (unsigned long long)stats.major_gcs, (unsigned long long)stats.analyses, + (unsigned long long)stats.freed_exports, (unsigned long long)stats.freed_imports, + stats.live_exports, stats.live_imports, scr_ni_live_exports(), scr_ni_live_handles(), + (double)stats.max_analysis_ns / 1e6, (double)stats.analysis_ns / 1e6); + } ni_on_node_stack(ni_stop_job, NULL); /* Node is gone: no representative can call in again. */ scr_ni_exports_release_all(); + scr_ni_gc_cleanup(); } /* The hosted loop's run_loop on Node's stack. */ diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 8dd800997..2029a5c16 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -154,6 +154,17 @@ int scr_ni_host_export_op(uint32_t id, uint32_t op, const struct ScrNodeValue *a struct ScrNodeValue *out); /* Promise settlement callback (SCR_NODE_ISLANDS build of scr_async.c). */ void scr_promise_on_settle_sync(ScrPromise *in, void (*cb)(ScrPromise *dst, ScrPromise *src)); +void *scr_promise_untraced_dyn_payload(const ScrPromise *p); + +/* ── cross-heap cycle collection (scope-B NI-8, scr_node_island_gc.c) ──── */ +const void *scr_ni_export_identity(uint32_t id); +void *scr_ni_gc_ref(const ScrDyn *box, size_t *rc, uint32_t *id); +void *scr_ni_gc_ref_info(void *ref, size_t *rc, uint32_t *id); +size_t scr_ni_gc_pins(void ***objs, void ***refs); +struct ScrNodeGcResult; +int scr_ni_host_gc_analyze(const uint32_t *exports, size_t nexports, + struct ScrNodeGcResult *out); +void scr_ni_gc_cleanup(void); /* at exit */ /* ── deferred island modules (scope-B NI-7, scr_node_island_boot.c) ───── * nodeIsland.defer(path, snapshot): a statically imported island module the diff --git a/packages/runtime/src/scr_node_island_export.c b/packages/runtime/src/scr_node_island_export.c index 453adde9a..b550e0347 100644 --- a/packages/runtime/src/scr_node_island_export.c +++ b/packages/runtime/src/scr_node_island_export.c @@ -26,7 +26,8 @@ * * When V8 collects a representative, the bridge reports export_release at * its next entry (never inside a GC) and the table drops its reference. - * Cycles through both heaps leak until NI-8. */ + * Cycles through both heaps are found by the analysis of + * scr_node_island_gc.c before each major GC (NI-8). */ #define _XOPEN_SOURCE 700 #define _DARWIN_C_SOURCE 1 #include "scr_runtime.h" @@ -272,6 +273,13 @@ void scr_ni_export_out(ScrDyn *v, ScrNodeValue *out) { } } +/* NI-8: the identity of export `id` (the referent its representative + * stands for), or NULL for a free id. */ +const void *scr_ni_export_identity(uint32_t id) { + if (id == 0 || id >= ni_exports_len) return NULL; + return ni_exports[id].identity; +} + uint32_t scr_ni_live_exports(void) { uint32_t n = 0; for (uint32_t i = 1; i < ni_exports_len; i++) n += ni_exports[i].value != NULL; diff --git a/packages/runtime/src/scr_node_island_gc.c b/packages/runtime/src/scr_node_island_gc.c new file mode 100644 index 000000000..e1d6f7957 --- /dev/null +++ b/packages/runtime/src/scr_node_island_gc.c @@ -0,0 +1,529 @@ +/* Node islands: cross-heap cycle collection (scope-B milestone NI-8). + * Linked with scr_node_island.c (the `nodeIsland` runtime feature). + * + * The two heaps hold each other through two tables. The export table holds + * native values whose V8 representatives JavaScript uses + * (scr_node_island_export.c), and the bridge's import table holds V8 + * objects the program uses (scr_node_island_handle.c). Each table is a root + * for its own collector, so a cycle through both heaps (a native object + * holding an island emitter whose listener is a native closure capturing + * that object) was never freed by either side. + * + * Before V8's major GC marks, the bridge asks this unit which exports the + * program's own roots still hold and what every other export reaches + * across the boundary. The answer comes from a restricted trial deletion + * (Bacon-Rajan) over the native subgraph under the exported values, + * through the same trace functions the cycle collector uses (ScrCycType): + * + * 1. Walk every object reachable from an exported value and count, for + * each, the references that come from inside that subgraph or from the + * export table. An object with references left over is held from + * outside: from a root, or from an object the walk did not reach. + * 2. Everything reachable from such an object is held natively (black). + * An export whose value is black is a root for V8; an import held by a + * black holder, or by anything outside the subgraph (its count is not + * explained by holders the walk saw), is a root too. + * 3. For every other export, walk its white subgraph and list the imports + * and the other exports it reaches. V8 marks those when, and only + * when, it marks the export's representative. + * + * This never under-approximates what the program can reach. An edge the + * walk does not count leaves a count unexplained, which only makes more + * objects black; so does a reference from an object the walk did not + * reach, and so does stopping the walk early (its budget). A white object + * is reachable only through the export table, so the program can reach it + * again only through a representative V8 still marks (the bridge roots any + * export JavaScript touches during the marking that follows). V8's own + * marker honors weak references, ephemerons (WeakMap) and + * FinalizationRegistry on its side; native weak collections hold their + * keys weakly and need the key in hand, so they open no path to a white + * object. + * + * Holders of imports are island handle boxes and the pin table, which + * holds a runtime promise standing for an island promise together with its + * ref: a pinned object the walk reaches counts as the holder of its ref. + * + * The walk reads counts and calls trace functions only: it writes no + * collector header and allocates nothing the collector sees. It refuses + * to answer (the bridge then roots everything for that GC) while a cycle + * collection is in progress, which it notices by a non-black header, and + * when counts are inconsistent. */ +#define _XOPEN_SOURCE 700 +#define _DARWIN_C_SOURCE 1 +#include "scr_runtime.h" +#include "scr_node_bridge.h" +#include "scr_node_island.h" + +#include +#include +#include +#include + +#if !defined(_WIN32) && !defined(__wasi__) + +/* One object of the walk: a headered native object, or (NI_REF) the + * ScrNodeRef of an island handle box. `count` starts at the object's + * reference count and loses one per reference the walk explains. */ +enum { NI_OBJ = 1, NI_REF = 2 }; +typedef struct { + void *key; + int64_t count; + uint32_t stamp; /* the reach walk that last saw it */ + uint32_t ident; /* 1 + index of the analyzed export whose referent this is, else 0 */ + uint32_t id; /* NI_REF: its import id (0: none) */ + uint32_t pin; /* NI_OBJ: 1 + index of its first pin, else 0 */ + uint8_t kind; + uint8_t black; /* held natively */ + uint8_t walked; /* NI_OBJ: its children were (or are queued to be) counted */ +} NiGcNode; + +static NiGcNode *gc_nodes = NULL; +static size_t gc_cap = 0, gc_len = 0; +static void **gc_work = NULL; +static size_t gc_nwork = 0, gc_work_cap = 0; +static bool gc_failed = false; +static size_t gc_budget = 0; + +/* The pin table as the walk sees it: pinned object -> its pins. */ +static void **gc_pin_obj = NULL, **gc_pin_ref = NULL; +static size_t gc_npins = 0; +static uint32_t *gc_pin_next = NULL; /* 1 + next pin of the same object */ +static size_t gc_pin_next_cap = 0; +typedef struct { + void *obj; + uint32_t first; /* 1 + index */ +} NiGcPinSlot; +static NiGcPinSlot *gc_pin_map = NULL; +static size_t gc_pin_map_cap = 0; + +/* Answer buffers, reused across GCs. */ +static uint32_t *gc_free = NULL; +static size_t gc_nfree = 0, gc_free_cap = 0; +static uint8_t *gc_rooted = NULL; +static uint32_t *gc_rstart = NULL, *gc_rlen = NULL; +static size_t gc_nexp_cap = 0; +static uint32_t *gc_reach = NULL; +static size_t gc_nreach = 0, gc_reach_cap = 0; + +SCR_COLD static void gc_oom(void) { scr_trap("scriptc: out of memory\n"); } + +static void *gc_realloc(void *p, size_t n) { + void *q = realloc(p, n); + if (q == NULL) gc_oom(); + return q; +} + +static size_t gc_hash(const void *p) { + uintptr_t x = (uintptr_t)p; + x ^= x >> 33; + x *= 0xff51afd7ed558ccdULL; + x ^= x >> 33; + return (size_t)x; +} + +static NiGcNode *gc_find_slot(NiGcNode *table, size_t cap, const void *key) { + size_t mask = cap - 1; + for (size_t i = gc_hash(key) & mask;; i = (i + 1) & mask) + if (table[i].key == NULL || table[i].key == key) return &table[i]; +} + +static void gc_grow(void) { + size_t cap = gc_cap ? gc_cap * 2 : 1024; + NiGcNode *table = calloc(cap, sizeof *table); + if (table == NULL) gc_oom(); + for (size_t i = 0; i < gc_cap; i++) + if (gc_nodes[i].key != NULL) *gc_find_slot(table, cap, gc_nodes[i].key) = gc_nodes[i]; + free(gc_nodes); + gc_nodes = table; + gc_cap = cap; +} + +static NiGcNode *gc_lookup(const void *key) { + if (gc_cap == 0 || key == NULL) return NULL; + NiGcNode *n = gc_find_slot(gc_nodes, gc_cap, key); + return n->key != NULL ? n : NULL; +} + +/* The slot of `key`, initialized as a new node of `kind` with `count` when + * it has none (*fresh). Node pointers stay valid until the next insertion. */ +static NiGcNode *gc_insert(void *key, int kind, int64_t count, bool *fresh) { + if ((gc_len + 1) * 2 > gc_cap) gc_grow(); + NiGcNode *n = gc_find_slot(gc_nodes, gc_cap, key); + *fresh = n->key == NULL; + if (*fresh) { + memset(n, 0, sizeof *n); + n->key = key; + n->kind = (uint8_t)kind; + n->count = count; + gc_len++; + } else if (n->kind != kind) { /* one pointer seen as two kinds: inconsistent */ + gc_failed = true; + } + return n; +} + +static void gc_push(void *obj) { + if (gc_nwork == gc_work_cap) { + gc_work_cap = gc_work_cap ? gc_work_cap * 2 : 1024; + gc_work = gc_realloc(gc_work, gc_work_cap * sizeof *gc_work); + } + gc_work[gc_nwork++] = obj; +} + +/* Objects the walk does not enter: none, immortal, published or compact + * (no count to explain; whatever they hold stays black). */ +static bool gc_skip(const void *obj) { + return obj == NULL || SCR_RC_COUNT(obj) >= SCR_RC_COMPACT_TAG; +} + +/* An island handle box: its ref is an import. */ +static bool gc_island_box(void *obj) { + if (scr_cyc_type(scr_cyc_hdr(obj))->kind != SCR_CYC_KIND_DYN) return false; + const ScrDyn *d = obj; + return d->kind == SCR_DYN_HANDLE && d->v.handle.tag == SCR_DYNH_NODE_ISLAND; +} + +/* ── pins ─────────────────────────────────────────────────────────────── */ + +static void gc_pins_load(void) { + gc_npins = scr_ni_gc_pins(&gc_pin_obj, &gc_pin_ref); + size_t cap = 64; + while (cap < gc_npins * 2) cap *= 2; + if (cap > gc_pin_map_cap) { + free(gc_pin_map); + gc_pin_map = malloc(cap * sizeof *gc_pin_map); + if (gc_pin_map == NULL) gc_oom(); + gc_pin_map_cap = cap; + } + memset(gc_pin_map, 0, gc_pin_map_cap * sizeof *gc_pin_map); + if (gc_npins > gc_pin_next_cap) { + gc_pin_next_cap = gc_npins * 2; + gc_pin_next = gc_realloc(gc_pin_next, gc_pin_next_cap * sizeof *gc_pin_next); + } + size_t mask = gc_pin_map_cap - 1; + for (size_t p = 0; p < gc_npins; p++) { + size_t i = gc_hash(gc_pin_obj[p]) & mask; + while (gc_pin_map[i].obj != NULL && gc_pin_map[i].obj != gc_pin_obj[p]) i = (i + 1) & mask; + gc_pin_next[p] = gc_pin_map[i].obj != NULL ? gc_pin_map[i].first : 0; + gc_pin_map[i].obj = gc_pin_obj[p]; + gc_pin_map[i].first = (uint32_t)p + 1; + } +} + +static uint32_t gc_pin_first(const void *obj) { + if (gc_npins == 0) return 0; + size_t mask = gc_pin_map_cap - 1; + for (size_t i = gc_hash(obj) & mask; gc_pin_map[i].obj != NULL; i = (i + 1) & mask) + if (gc_pin_map[i].obj == obj) return gc_pin_map[i].first; + return 0; +} + +/* ── phase 1: count the references the subgraph explains ──────────────── */ + +/* Every child of `obj` the walk counts: what its trace visits, plus the + * counted references the cycle collector leaves untraced but that can lead + * across the boundary (a dyn promise box's promise, a promise's untraced + * dyn payload). Counting more owned edges is always sound: an edge the + * walk counts is one it also follows. */ +static void gc_children(void *obj, ScrTraceVisit visit) { + const ScrCycType *type = scr_cyc_type(scr_cyc_hdr(obj)); + type->trace(obj, visit, NULL); + if (type->kind == SCR_CYC_KIND_DYN) { + const ScrDyn *d = obj; + if (d->kind == SCR_DYN_PROMISE && d->v.promise != NULL) visit(d->v.promise, NULL); + } else if (type->kind == SCR_CYC_KIND_PROMISE) { + void *payload = scr_promise_untraced_dyn_payload(obj); + if (payload != NULL) visit(payload, NULL); + } +} + +/* The refs `obj` holds across the boundary: an island box's ref, and the + * refs of the pins of a pinned object. */ +static void gc_refs_of(void *obj, uint32_t pin, void (*fn)(void *ref)) { + if (gc_island_box(obj)) { + size_t rc; + uint32_t id; + void *ref = scr_ni_gc_ref(obj, &rc, &id); + if (ref != NULL) fn(ref); + } + for (uint32_t p = pin; p != 0; p = gc_pin_next[p - 1]) fn(gc_pin_ref[p - 1]); +} + +/* The node of a headered object, created on first sight: the pin table + * holds a pinned object once per pin, which the count starts without. */ +static NiGcNode *gc_obj_node(void *obj) { + bool fresh; + NiGcNode *n = gc_insert(obj, NI_OBJ, SCR_RC_COUNT(obj), &fresh); + if (fresh) { + n->pin = gc_pin_first(obj); + for (uint32_t p = n->pin; p != 0; p = gc_pin_next[p - 1]) n->count--; + } + return n; +} + +/* Reached through a counted edge: queued for counting its own children. */ +static NiGcNode *gc_obj(void *obj) { + NiGcNode *n = gc_obj_node(obj); + if (!n->walked && n->kind == NI_OBJ) { + n->walked = 1; + gc_push(obj); + } + return n; +} + +static void gc_count_visit(void *child, void *ctx) { + (void)ctx; + if (gc_skip(child)) return; + gc_obj(child)->count--; +} + +static void gc_count_ref(void *ref) { + size_t rc = 0; + uint32_t id = 0; + scr_ni_gc_ref_info(ref, &rc, &id); + bool fresh; + NiGcNode *n = gc_insert(ref, NI_REF, (int64_t)rc, &fresh); + if (fresh) n->id = id; + n->count--; +} + +/* Counts the subgraph under the queued objects, until the budget. */ +static void gc_count(void) { + while (gc_nwork > 0 && !gc_failed && gc_len <= gc_budget) { + void *obj = gc_work[--gc_nwork]; + /* Outside a collection every live object is black. */ + if (scr_cyc_color(scr_cyc_hdr(obj)) != SCR_CYC_BLACK) { + gc_failed = true; + return; + } + gc_children(obj, gc_count_visit); + gc_refs_of(obj, gc_lookup(obj)->pin, gc_count_ref); + } + /* Left over when the budget ran out: their children were not counted, + * so they must not pass for walked (they become black). */ + while (gc_nwork > 0) { + NiGcNode *n = gc_lookup(gc_work[--gc_nwork]); + if (n != NULL) n->walked = 0; + } +} + +/* ── phase 2: blacken what is held from outside ───────────────────────── */ + +static void gc_black_visit(void *child, void *ctx) { + (void)ctx; + if (gc_skip(child)) return; + NiGcNode *n = gc_lookup(child); + if (n != NULL && !n->black) { + n->black = 1; + if (n->walked) gc_push(child); + } +} + +static void gc_black_ref(void *ref) { + NiGcNode *r = gc_lookup(ref); + if (r != NULL) r->black = 1; +} + +static void gc_blacken(void) { + for (size_t i = 0; i < gc_cap; i++) { + NiGcNode *n = &gc_nodes[i]; + if (n->key == NULL) continue; + if (n->count < 0) { /* more references explained than counted */ + gc_failed = true; + return; + } + /* Held from outside, or never walked: its children are unknown, and + * what it holds is a root as far as this analysis can tell. */ + if (n->kind == NI_OBJ && (n->count > 0 || !n->walked) && !n->black) { + n->black = 1; + if (n->walked) gc_push(n->key); + } + while (gc_nwork > 0) { + void *obj = gc_work[--gc_nwork]; + gc_children(obj, gc_black_visit); + gc_refs_of(obj, gc_lookup(obj)->pin, gc_black_ref); + } + } +} + +/* ── phase 3: what each white export reaches ──────────────────────────── */ + +static uint32_t gc_stamp = 0; +static uint32_t gc_walk_export; /* 1 + index of the export being walked */ +static const uint32_t *gc_exports_in; + +static void gc_reach_add(uint32_t entry) { + if (gc_nreach == gc_reach_cap) { + gc_reach_cap = gc_reach_cap ? gc_reach_cap * 2 : 256; + gc_reach = gc_realloc(gc_reach, gc_reach_cap * sizeof *gc_reach); + } + gc_reach[gc_nreach++] = entry; +} + +static void gc_reach_visit(void *child, void *ctx) { + (void)ctx; + if (gc_skip(child)) return; + NiGcNode *n = gc_lookup(child); + if (n == NULL || n->black || n->stamp == gc_stamp) return; + n->stamp = gc_stamp; + /* Another white export's referent: V8 marks its representative, whose + * own reach covers the rest. */ + if (n->ident != 0 && n->ident != gc_walk_export && !gc_rooted[n->ident - 1]) { + gc_reach_add(gc_exports_in[n->ident - 1] | SCR_NODE_GC_EXPORT); + return; + } + gc_push(child); +} + +static void gc_reach_ref(void *ref) { + NiGcNode *r = gc_lookup(ref); + if (r != NULL && !r->black && r->count == 0 && r->id != 0 && r->stamp != gc_stamp) { + r->stamp = gc_stamp; + gc_reach_add(r->id); + } +} + +/* White objects were all walked: an unwalked one is black. */ +static void gc_reach_walk(void *start) { + NiGcNode *s = gc_lookup(start); + if (s == NULL || s->black) return; + s->stamp = gc_stamp; + gc_push(start); + while (gc_nwork > 0) { + void *obj = gc_work[--gc_nwork]; + gc_children(obj, gc_reach_visit); + gc_refs_of(obj, gc_lookup(obj)->pin, gc_reach_ref); + } +} + +static void gc_reserve_exports(size_t n) { + if (n <= gc_nexp_cap) return; + size_t cap = gc_nexp_cap ? gc_nexp_cap : 64; + while (cap < n) cap *= 2; + gc_rooted = gc_realloc(gc_rooted, cap); + gc_rstart = gc_realloc(gc_rstart, cap * sizeof *gc_rstart); + gc_rlen = gc_realloc(gc_rlen, cap * sizeof *gc_rlen); + gc_nexp_cap = cap; +} + +static void gc_free_add(uint32_t id) { + if (gc_nfree == gc_free_cap) { + gc_free_cap = gc_free_cap ? gc_free_cap * 2 : 256; + gc_free = gc_realloc(gc_free, gc_free_cap * sizeof *gc_free); + } + gc_free[gc_nfree++] = id; +} + +/* Clears the table for a walk like the last one, dropping a table much + * larger than that walk needs. */ +static void gc_reset(size_t expect) { + size_t want = 1024; + while (want < expect * 2) want *= 2; + if (gc_cap > 4 * want) { + free(gc_nodes); + gc_nodes = NULL; + gc_cap = 0; + } else if (gc_len > 0) { + memset(gc_nodes, 0, gc_cap * sizeof *gc_nodes); + } + gc_len = 0; + gc_nwork = 0; + gc_nfree = 0; + gc_nreach = 0; + gc_failed = false; +} + +/* The objects one analysis may walk before the exports it has not reached + * yet count as held: a bounded pause, at the price of collecting their + * cycles at a later GC. */ +#define NI_GC_BUDGET ((size_t)1 << 20) + +int scr_ni_host_gc_analyze(const uint32_t *exports, size_t n, ScrNodeGcResult *out) { + static size_t last_len = 0; + memset(out, 0, sizeof *out); + gc_reset(last_len); + gc_reserve_exports(n > 0 ? n : 1); + out->rooted = gc_rooted; + out->reach_start = gc_rstart; + out->reach_len = gc_rlen; + if (n == 0) return 0; /* nothing exported: no cycle can pass through V8 */ + gc_exports_in = exports; + gc_budget = NI_GC_BUDGET + 8 * n; + gc_pins_load(); + + /* The exports in the bridge's order (newest first), each referent marked + * so the walk recognizes it, and each value box, which the export table + * holds once, counted from. Once the budget runs out the remaining + * exports stay roots. */ + for (size_t i = 0; i < n; i++) { + gc_rstart[i] = gc_rlen[i] = 0; + ScrDyn *v = scr_ni_export_value(exports[i]); + gc_rooted[i] = v == NULL || gc_skip(v); + } + for (size_t i = 0; i < n && !gc_failed; i++) { + if (gc_rooted[i]) continue; + if (gc_len > gc_budget) { + gc_rooted[i] = 1; + continue; + } + const void *ident = scr_ni_export_identity(exports[i]); + if (ident != NULL && !gc_skip(ident)) gc_obj_node((void *)ident)->ident = (uint32_t)i + 1; + gc_obj(scr_ni_export_value(exports[i]))->count--; + gc_count(); + } + if (!gc_failed) gc_blacken(); + last_len = gc_len; + if (gc_failed) return 1; + + for (size_t i = 0; i < n; i++) { + if (gc_rooted[i]) continue; + NiGcNode *nd = gc_lookup(scr_ni_export_value(exports[i])); + if (nd == NULL || nd->black) gc_rooted[i] = 1; + } + for (size_t i = 0; i < n; i++) { + gc_rstart[i] = (uint32_t)gc_nreach; + if (!gc_rooted[i]) { + if (++gc_stamp == 0) { /* wrapped: clear the stamps */ + for (size_t k = 0; k < gc_cap; k++) gc_nodes[k].stamp = 0; + gc_stamp = 1; + } + gc_walk_export = (uint32_t)i + 1; + gc_reach_walk(scr_ni_export_value(exports[i])); + } + gc_rlen[i] = (uint32_t)gc_nreach - gc_rstart[i]; + } + + /* Imports whose every holder is white. */ + for (size_t k = 0; k < gc_cap; k++) { + NiGcNode *r = &gc_nodes[k]; + if (r->key != NULL && r->kind == NI_REF && !r->black && r->count == 0 && r->id != 0) + gc_free_add(r->id); + } + out->free_imports = gc_free; + out->nfree_imports = gc_nfree; + out->reach = gc_reach; + out->visited = gc_len; + return 0; +} + +void scr_ni_gc_cleanup(void) { + free(gc_nodes); + free(gc_work); + free(gc_free); + free(gc_rooted); + free(gc_rstart); + free(gc_rlen); + free(gc_reach); + free(gc_pin_next); + free(gc_pin_map); + gc_nodes = NULL; + gc_work = NULL; + gc_free = NULL; + gc_rooted = NULL; + gc_rstart = gc_rlen = gc_reach = gc_pin_next = NULL; + gc_pin_map = NULL; + gc_cap = gc_len = gc_nwork = gc_work_cap = gc_free_cap = gc_nfree = 0; + gc_nexp_cap = gc_nreach = gc_reach_cap = gc_pin_next_cap = gc_pin_map_cap = 0; +} + +#endif diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 960499af2..81ce215b9 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -7,8 +7,10 @@ * per V8 value while the runtime holds it and the runtime keeps one ref per * id (ni_refs), so the same V8 object always comes back as the same ref: * `===` compares handle pointers and identity is exact. When the last box - * releases a ref, the bridge drops its Global and V8 may collect the - * object (acyclic garbage; cycles across both heaps wait for NI-8). + * releases a ref, the bridge drops its reference and V8 may collect the + * object. Cycles across both heaps are collected by NI-8 + * (scr_node_island_gc.c): V8 keeps an import alive only while the runtime + * may still use it. * * Boundary rules (DESIGN §2.5.1): undefined, null, booleans, numbers * (exact: -0, NaN), strings (WTF-8, lone surrogates kept) and BigInt are @@ -1242,6 +1244,44 @@ ScrDyn *scr_ni_value_in(const ScrNodeValue *v) { return d != NULL ? d : scr_dyn_retain(scr_dyn_undefined()); } +/* NI-8: the pinned runtime objects and the refs they stand for (the pin + * table holds one reference to each). */ +size_t scr_ni_gc_pins(void ***objs, void ***refs) { + static void **o = NULL, **r = NULL; + static size_t cap = 0; + if (nh_npins > cap) { + cap = nh_npins * 2; + void **go = realloc(o, cap * sizeof *go); + if (go == NULL) nh_oom(); + o = go; + void **gr = realloc(r, cap * sizeof *gr); + if (gr == NULL) nh_oom(); + r = gr; + } + for (size_t i = 0; i < nh_npins; i++) { + o[i] = nh_pins[i].obj; + r[i] = nh_pins[i].ref; + } + *objs = o; + *refs = r; + return nh_npins; +} + +/* NI-8: the ref an island handle box holds, its reference count and its + * import id (0 unless it is the id's own ref: stubs, aliases and refs + * whose import was transplanted have none). */ +void *scr_ni_gc_ref_info(void *r, size_t *rc, uint32_t *id) { + ScrNodeRef *ref = r; + if (ref == NULL) return NULL; + *rc = ref->rc; + *id = ref->id != 0 && ref->id < ni_refs_cap && ni_refs[ref->id] == ref ? ref->id : 0; + return ref; +} + +void *scr_ni_gc_ref(const ScrDyn *box, size_t *rc, uint32_t *id) { + return scr_ni_gc_ref_info(box->v.handle.ptr, rc, id); +} + uint32_t scr_ni_live_handles(void) { const ScrNodeBridge *b = scr_ni_bridge_loaded(); if (b == NULL) return 0; @@ -1276,6 +1316,16 @@ static ScrDyn *nh_op_invoke(void *h, ScrDyn *self, const char *method, ScrDyn *c (void)h; ScrDyn *fn = scr_ni_get_str(self, method, strlen(method)); if (fn == NULL) return NULL; + /* A native function stored on the island object (its representative + * crossed back as the native value): call it here, with the island + * object as `this`, as V8 would call the representative. */ + if (fn->kind == SCR_DYN_FUNC) { + scr_dyn_this_push_dyn(self); + ScrDyn *result = scr_dyn_call(fn, args, argc, what != NULL ? what : method); + scr_dyn_this_pop(); + scr_dyn_release(fn); + return result; + } if (!scr_ni_is_function(fn)) { scr_dyn_release(fn); char message[512]; diff --git a/tests/corpus/6890-node-island-gc-emitter/lib.cjs b/tests/corpus/6890-node-island-gc-emitter/lib.cjs new file mode 100644 index 000000000..bdca18663 --- /dev/null +++ b/tests/corpus/6890-node-island-gc-emitter/lib.cjs @@ -0,0 +1,34 @@ +"use strict"; +const { EventEmitter } = require("node:events"); + +class Ticker extends EventEmitter { + constructor() { + super(); + // Each emitter carries some weight, so leaked cycles show in memory. + this.payload = new Array(256).fill(0).map((_, i) => ({ i })); + } + tick(n) { + return this.emit("tick", n); + } +} + +// Allocates and drops JavaScript garbage so V8 runs its collector. +function churn(kib) { + let total = 0; + for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length; + return total; +} + +// Emitters island code keeps alive on its own (a registry), whose native +// listeners must keep working whatever the collector does. +const kept = new Set(); +function keep(emitter) { + kept.add(emitter); +} +function tickAll(n) { + let delivered = 0; + for (const e of kept) if (e.tick(n)) delivered++; + return delivered; +} + +module.exports = { Ticker, churn, keep, kept, tickAll }; diff --git a/tests/corpus/6890-node-island-gc-emitter/main.mjs b/tests/corpus/6890-node-island-gc-emitter/main.mjs new file mode 100644 index 000000000..196ec0023 --- /dev/null +++ b/tests/corpus/6890-node-island-gc-emitter/main.mjs @@ -0,0 +1,43 @@ +// @requires-libnode +// Cross-heap cycles through an island emitter (scope-B NI-8): a native +// object holds an island EventEmitter whose listener is a native closure +// capturing that object. Neither heap alone can free such a pair; the +// collector frees the pairs nothing holds and keeps the ones either side +// still holds, which keep working. +import { requireModule } from "@scriptc/node"; + +const { churn, keep, kept, Ticker, tickAll } = requireModule("./lib.cjs"); + +class Counter { + constructor(name) { + this.name = name; + this.hits = 0; + this.ticker = new Ticker(); + this.ticker.on("tick", (n) => { + this.hits += n; + }); + } +} + +// Pairs held by native code, and pairs held by island code only. +const nativeKept = []; +let islandHeld = 0; +let total = 0; +for (let i = 0; i < 4000; i++) { + const c = new Counter(`c${i}`); + c.ticker.tick(1); + total += c.hits; + if (i % 500 === 0) nativeKept.push(c); + if (i % 700 === 0) { + keep(c.ticker); + islandHeld++; + } + churn(4); +} +console.log("total", total, "native kept", nativeKept.length, "island kept", islandHeld); + +// After the churn, every kept pair still works from both sides. +for (const c of nativeKept) c.ticker.tick(10); +console.log("native kept hits", nativeKept.map((c) => c.hits).join(",")); +console.log("island delivered", tickAll(100), "registry", kept.size); +console.log("native kept hits", nativeKept.map((c) => c.hits).join(",")); diff --git a/tests/corpus/6891-node-island-gc-closure/lib.cjs b/tests/corpus/6891-node-island-gc-closure/lib.cjs new file mode 100644 index 000000000..608c821e1 --- /dev/null +++ b/tests/corpus/6891-node-island-gc-closure/lib.cjs @@ -0,0 +1,22 @@ +"use strict"; +// Island objects that hold native callbacks. +function make(n) { + return { n, payload: new Array(128).fill(n), onPing: null, onBoth: null }; +} +function ping(o, k) { + return o.onPing(k); +} +function churn(kib) { + let total = 0; + for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length; + return total; +} +// Island-side roots. +const held = []; +function hold(o) { + held.push(o); +} +function pingHeld(k) { + return held.map((o) => o.onPing(k)).join(","); +} +module.exports = { make, ping, churn, hold, pingHeld }; diff --git a/tests/corpus/6891-node-island-gc-closure/main.mjs b/tests/corpus/6891-node-island-gc-closure/main.mjs new file mode 100644 index 000000000..70c95f163 --- /dev/null +++ b/tests/corpus/6891-node-island-gc-closure/main.mjs @@ -0,0 +1,34 @@ +// @requires-libnode +// Cross-heap cycles through native closures (scope-B NI-8): an island +// object holds a native closure that captures that island object, and a +// pair of island objects hold closures capturing each other. The collector +// frees the cycles nothing holds; those held by native code (a native +// closure, a native array) or by island code keep working. +import { requireModule } from "@scriptc/node"; + +const { churn, hold, make, ping, pingHeld } = requireModule("./lib.cjs"); + +const nativeHeld = []; +const viaClosure = []; +let total = 0; +for (let i = 0; i < 4000; i++) { + const o = make(i); + o.onPing = (k) => o.n + k + o.payload.length; + total += ping(o, 1); + + // Two island objects whose closures capture each other. + const a = make(i); + const b = make(-i); + a.onPing = (k) => b.n * k; + b.onPing = (k) => a.n * k; + total += ping(a, 2) + ping(b, 3); + + if (i % 800 === 0) nativeHeld.push(o); + if (i % 900 === 0) viaClosure.push(() => ping(a, 1) + ping(b, 1)); + if (i % 1000 === 0) hold(b); + churn(4); +} +console.log("total", total); +console.log("native held", nativeHeld.map((o) => ping(o, 0)).join(",")); +console.log("via closure", viaClosure.map((f) => f()).join(",")); +console.log("island held", pingHeld(5)); diff --git a/tests/corpus/6892-node-island-gc-native-subclass/main.ts b/tests/corpus/6892-node-island-gc-native-subclass/main.ts new file mode 100644 index 000000000..bf0aca24d --- /dev/null +++ b/tests/corpus/6892-node-island-gc-native-subclass/main.ts @@ -0,0 +1,35 @@ +// @requires-libnode +// Cross-heap cycles through native subclasses of an island class (scope-B +// NI-8): the instance is an island object; its native constructor registers +// a native listener that captures the instance. The pairs nothing holds are +// freed; those native code or island code holds keep working. +import { adopt, broadcast, Channel, churn } from "channel-pkg"; + +class CountingChannel extends Channel { + count = 0; + constructor(name: string) { + super(name); + this.onMessage((text: string) => { + this.count += text.length; + }); + } + summary(): string { + return `${this.name}:${this.count}`; + } +} + +const nativeHeld: CountingChannel[] = []; +let total = 0; +for (let i = 0; i < 3000; i++) { + const c = new CountingChannel(`c${i}`); + total += c.deliver("ping"); + total += c.count; + if (i % 600 === 0) nativeHeld.push(c); + if (i % 700 === 0) adopt(c); + churn(4); +} +console.log("total", total); +for (const c of nativeHeld) c.deliver("again"); +console.log("native held", nativeHeld.map((c) => c.summary()).join(",")); +console.log("island delivered", broadcast("hi")); +console.log("native held", nativeHeld.map((c) => c.summary()).join(",")); diff --git a/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts new file mode 100644 index 000000000..e5fece5c4 --- /dev/null +++ b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts @@ -0,0 +1,9 @@ +export declare class Channel { + name: string; + constructor(name: string); + onMessage(fn: (text: string) => void): void; + deliver(text: string): number; +} +export declare function churn(kib: number): number; +export declare function adopt(channel: Channel): void; +export declare function broadcast(text: string): number; diff --git a/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js new file mode 100644 index 000000000..c18a85e59 --- /dev/null +++ b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js @@ -0,0 +1,35 @@ +"use strict"; +// An island class whose instances keep the listeners they are given. +class Channel { + constructor(name) { + this.name = name; + this.listeners = []; + this.payload = new Array(128).fill(0); + } + onMessage(fn) { + this.listeners.push(fn); + } + deliver(text) { + for (const fn of this.listeners) fn(text); + return this.listeners.length; + } +} + +function churn(kib) { + let total = 0; + for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length; + return total; +} + +// Channels island code keeps on its own. +const adopted = []; +function adopt(channel) { + adopted.push(channel); +} +function broadcast(text) { + let n = 0; + for (const c of adopted) n += c.deliver(text); + return n; +} + +module.exports = { Channel, churn, adopt, broadcast }; diff --git a/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json new file mode 100644 index 000000000..c10cd58a3 --- /dev/null +++ b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json @@ -0,0 +1 @@ +{ "name": "channel-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6892-node-island-gc-native-subclass/package.json b/tests/corpus/6892-node-island-gc-native-subclass/package.json new file mode 100644 index 000000000..6fb11b47f --- /dev/null +++ b/tests/corpus/6892-node-island-gc-native-subclass/package.json @@ -0,0 +1 @@ +{ "name": "island-gc-native-subclass", "private": true, "type": "module", "scriptc": { "packages": { "channel-pkg": "node" } } } diff --git a/tests/corpus/6893-node-island-gc-shared-module/main.mjs b/tests/corpus/6893-node-island-gc-shared-module/main.mjs new file mode 100644 index 000000000..47e3ba0e2 --- /dev/null +++ b/tests/corpus/6893-node-island-gc-shared-module/main.mjs @@ -0,0 +1,20 @@ +// @requires-libnode +// Cross-heap cycles through a shared module (scope-B NI-8): island code +// pairs native sessions of a program module it shares with island peers +// that point back at them. The pairs island code drops are garbage on both +// sides; the ones the shared module keeps stay alive and keep working. +import { requireModule } from "@scriptc/node"; +import { openSessions } from "./sessions.mjs"; + +const { churn, pair } = requireModule("./peers.mjs"); + +let total = 0; +for (let i = 0; i < 3000; i++) { + total += pair(i, i % 500 === 0); + churn(4); +} +console.log("total", total); +const open = openSessions(); +console.log("open", open.length, open.map((s) => s.id).join(",")); +console.log("sent", open.map((s) => s.send("again")).join(",")); +console.log("logs", open.map((s) => s.log.length).join(",")); diff --git a/tests/corpus/6893-node-island-gc-shared-module/peers.mjs b/tests/corpus/6893-node-island-gc-shared-module/peers.mjs new file mode 100644 index 000000000..e2edd234c --- /dev/null +++ b/tests/corpus/6893-node-island-gc-shared-module/peers.mjs @@ -0,0 +1,30 @@ +// Island code: island peers paired with native sessions of the shared +// module, each pair a cycle across both heaps. +import { keep, Session } from "./sessions.mjs"; + +class Peer { + constructor(session) { + this.session = session; + this.received = 0; + this.payload = new Array(128).fill(1); + } + receive(text) { + this.received += text.length; + return this.received; + } +} + +export function pair(id, keepIt) { + const session = new Session(id); + const peer = new Peer(session); + session.peer = peer; + const n = session.send(`hello ${id}`); + if (keepIt) keep(session); + return n; +} + +export function churn(kib) { + let total = 0; + for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length; + return total; +} diff --git a/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs b/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs new file mode 100644 index 000000000..46da173dd --- /dev/null +++ b/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs @@ -0,0 +1,20 @@ +// A program module island code imports too (shared, scope-B NI-6): native +// sessions that island code pairs with island peers. +export class Session { + constructor(id) { + this.id = id; + this.peer = null; + this.log = []; + } + send(text) { + this.log.push(text); + return this.peer ? this.peer.receive(text) : 0; + } +} +const open = []; +export function keep(session) { + open.push(session); +} +export function openSessions() { + return open; +} diff --git a/tests/corpus/6894-node-island-gc-promises/lib.cjs b/tests/corpus/6894-node-island-gc-promises/lib.cjs new file mode 100644 index 000000000..444e20801 --- /dev/null +++ b/tests/corpus/6894-node-island-gc-promises/lib.cjs @@ -0,0 +1,38 @@ +"use strict"; +// Promises that tie island objects to native values. +function make(n) { + return { n, payload: new Array(128).fill(n), settle: null, promise: null }; +} +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve, payload: new Array(64).fill(0) }; +} +function settle(o, v) { + o.settle(v); +} +function churn(kib) { + let total = 0; + for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length; + return total; +} +const held = []; +function hold(x) { + held.push(x); +} +function resolveHeld() { + let k = 0; + for (const x of held) if (x.resolve) x.resolve(++k); + return k; +} +async function awaitHeld() { + const out = []; + for (const x of held) { + const v = await x.promise; + out.push(typeof v === "number" ? v : v.n); + } + return out.join(","); +} +module.exports = { make, deferred, settle, churn, hold, resolveHeld, awaitHeld }; diff --git a/tests/corpus/6894-node-island-gc-promises/main.mjs b/tests/corpus/6894-node-island-gc-promises/main.mjs new file mode 100644 index 000000000..a133df91f --- /dev/null +++ b/tests/corpus/6894-node-island-gc-promises/main.mjs @@ -0,0 +1,40 @@ +// @requires-libnode +// Cross-heap cycles through promises (scope-B NI-8): an island object holds +// a native promise that settles with that island object; a native promise +// reaction to an island promise captures the island object holding it. The +// collector frees the settled cycles nothing holds, and never a pending +// one that will still settle. +import { requireModule } from "@scriptc/node"; + +const { awaitHeld, churn, deferred, hold, make, resolveHeld, settle } = requireModule("./lib.cjs"); + +const pending = []; +let total = 0; +for (let i = 0; i < 3000; i++) { + // island object -> native promise -> settled with the island object + const o = make(i); + o.promise = new Promise((resolve) => { + o.settle = (v) => resolve(v); + }); + settle(o, o); + total += (await o.promise).n; + + // island deferred -> native reaction -> captures the deferred + const d = deferred(); + const reaction = d.promise.then((v) => v + d.payload.length); + d.resolve(i); + total += await reaction; + + // Pending cycles, settled only after the loop. + if (i % 600 === 0) { + const late = deferred(); + pending.push(late.promise.then((v) => v * 2 + late.payload.length)); + hold(late); + } + if (i % 700 === 0) hold(o); + churn(4); +} +console.log("total", total); +console.log("resolved", resolveHeld()); +console.log("pending", (await Promise.all(pending)).join(",")); +console.log("held", await awaitHeld()); diff --git a/tests/corpus/6895-node-island-gc-weakmap/lib.cjs b/tests/corpus/6895-node-island-gc-weakmap/lib.cjs new file mode 100644 index 000000000..fc3a780a0 --- /dev/null +++ b/tests/corpus/6895-node-island-gc-weakmap/lib.cjs @@ -0,0 +1,32 @@ +"use strict"; +// An island WeakMap keyed by native objects, and WeakRefs to them. +const notes = new WeakMap(); +function note(key, value) { + notes.set(key, value); +} +function noteOf(key) { + return notes.get(key); +} +function has(key) { + return notes.has(key); +} +function make(n) { + return { n, payload: new Array(128).fill(n), read: null }; +} +function read(v) { + return v.read(); +} +const refs = []; +function watch(x) { + refs.push(new WeakRef(x)); +} +// Watched objects native code still holds must be alive and the same. +function watchedAlive(objs) { + return refs.filter((r, i) => r.deref() === objs[i]).length; +} +function churn(kib) { + let total = 0; + for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length; + return total; +} +module.exports = { note, noteOf, has, make, read, watch, watchedAlive, churn }; diff --git a/tests/corpus/6895-node-island-gc-weakmap/main.mjs b/tests/corpus/6895-node-island-gc-weakmap/main.mjs new file mode 100644 index 000000000..3f5f2e43c --- /dev/null +++ b/tests/corpus/6895-node-island-gc-weakmap/main.mjs @@ -0,0 +1,30 @@ +// @requires-libnode +// Cross-heap cycles through an island WeakMap (scope-B NI-8): a native key +// object maps to an island value that holds a native closure capturing the +// key. In Node the entry dies with the key; across the boundary the +// collector must honor the same ephemeron semantics, and keep the entries +// (and WeakRefs) of keys native code still holds. +import { requireModule } from "@scriptc/node"; + +const { churn, has, make, note, noteOf, read, watch, watchedAlive } = requireModule("./lib.cjs"); + +const kept = []; +let total = 0; +for (let i = 0; i < 4000; i++) { + const key = { i, label: `k${i}` }; + const value = make(i); + value.read = () => key.i + key.label.length; + key.value = value; + note(key, value); + total += read(noteOf(key)); + if (i % 500 === 0) { + kept.push(key); + watch(key); + } + churn(4); +} +console.log("total", total); +console.log("kept entries", kept.filter((k) => has(k)).length, "of", kept.length); +console.log("kept values", kept.map((k) => read(noteOf(k))).join(",")); +console.log("same value", kept.every((k) => noteOf(k) === k.value)); +console.log("watched alive", watchedAlive(kept), "of", kept.length); diff --git a/tests/corpus/6896-node-island-native-method-member/lib.cjs b/tests/corpus/6896-node-island-native-method-member/lib.cjs new file mode 100644 index 000000000..923febff4 --- /dev/null +++ b/tests/corpus/6896-node-island-native-method-member/lib.cjs @@ -0,0 +1,9 @@ +"use strict"; +// Island objects that hold whatever functions they are given. +function make(n) { + return { n, label: `item-${n}`, cb: null, fmt: null, nested: { run: null } }; +} +function callFromIsland(o, ...args) { + return o.cb(...args); +} +module.exports = { make, callFromIsland }; diff --git a/tests/corpus/6896-node-island-native-method-member/main.mjs b/tests/corpus/6896-node-island-native-method-member/main.mjs new file mode 100644 index 000000000..4474e06d1 --- /dev/null +++ b/tests/corpus/6896-node-island-native-method-member/main.mjs @@ -0,0 +1,40 @@ +// @requires-libnode +// Native functions stored on island objects and called as methods from +// native code: the island object is the receiver, arguments and results +// cross both ways, throws propagate, and island code calling the same +// member sees the same function. +import { requireModule } from "@scriptc/node"; + +const { callFromIsland, make } = requireModule("./lib.cjs"); + +const o = make(3); +o.cb = (a, b) => o.n * a + b; +console.log("arrow", o.cb(2, 1), callFromIsland(o, 2, 1)); + +o.fmt = function (prefix) { + return `${prefix}:${this.label}:${this.n}`; +}; +console.log("this", o.fmt("p"), o.fmt.call(make(9), "q")); + +o.nested.run = (x) => [x, x * 2]; +console.log("nested", o.nested.run(4).join(",")); + +o.cb = () => { + throw new RangeError("from native member"); +}; +try { + o.cb(); +} catch (e) { + console.log("caught", e instanceof RangeError, e.message); +} + +console.log("optional", o.missing?.(1), typeof o.cb); +try { + o.label(); +} catch (e) { + console.log("not callable", e instanceof TypeError); +} + +const items = [make(1), make(2), make(3)]; +for (const it of items) it.cb = (k) => it.n + k; +console.log("loop", items.map((it) => it.cb(10)).join(",")); diff --git a/tests/fixtures/node-island/gc-cycles-peers.mjs b/tests/fixtures/node-island/gc-cycles-peers.mjs new file mode 100644 index 000000000..c9efa547b --- /dev/null +++ b/tests/fixtures/node-island/gc-cycles-peers.mjs @@ -0,0 +1,23 @@ +// Island code for gc-cycles.mjs: island peers paired with native sessions +// of a shared module, each pair a cycle across both heaps. +import { keep, Session } from "./gc-cycles-sessions.mjs"; + +class Peer { + constructor(session) { + this.session = session; + this.received = 0; + this.payload = new Array(64).fill(1); + } + receive(text) { + this.received += text.length; + return this.received; + } +} + +export function pair(id, keepIt) { + const session = new Session(id); + session.peer = new Peer(session); + const n = session.send(`hello ${id}`); + if (keepIt) keep(session); + return n; +} diff --git a/tests/fixtures/node-island/gc-cycles-sessions.mjs b/tests/fixtures/node-island/gc-cycles-sessions.mjs new file mode 100644 index 000000000..f5116d04a --- /dev/null +++ b/tests/fixtures/node-island/gc-cycles-sessions.mjs @@ -0,0 +1,18 @@ +// A program module the island half of gc-cycles.mjs imports too (a shared +// module, scope-B NI-6): native sessions paired with island peers. +export class Session { + constructor(id) { + this.id = id; + this.peer = null; + } + send(text) { + return this.peer ? this.peer.receive(text) : 0; + } +} +const open = []; +export function keep(session) { + open.push(session); +} +export function openSessions() { + return open; +} diff --git a/tests/fixtures/node-island/gc-cycles.cjs b/tests/fixtures/node-island/gc-cycles.cjs new file mode 100644 index 000000000..0b7a3c97d --- /dev/null +++ b/tests/fixtures/node-island/gc-cycles.cjs @@ -0,0 +1,69 @@ +"use strict"; +// The island half of gc-cycles.mjs (node-island-gc.test.ts). +const { EventEmitter } = require("node:events"); +const v8 = require("node:v8"); +const vm = require("node:vm"); + +v8.setFlagsFromString("--expose-gc"); +const gc = vm.runInNewContext("gc"); +v8.setFlagsFromString("--no-expose-gc"); + +class Ticker extends EventEmitter { + constructor() { + super(); + this.payload = new Array(64).fill(0); + } + tick(n) { + return this.emit("tick", n); + } +} + +// An island object that will hold a native closure capturing it. +function box(n) { + return { n, payload: new Array(64).fill(n), cb: null }; +} +function call(o) { + return o.cb(); +} + +function deferred() { + let resolve; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +// An island WeakMap keyed by native objects. +const map = new WeakMap(); +function remember(key, value) { + map.set(key, value); +} +function recall(key) { + return map.get(key); +} + +// Island-side holders: strong (a Set) and weak (WeakRefs). +const held = new Set(); +function hold(x) { + held.add(x); +} +const weak = []; +function watch(x) { + weak.push(new WeakRef(x)); +} +// How many watched objects are still alive, and whether each is usable. +function alive() { + let n = 0; + for (const r of weak) if (r.deref() !== undefined) n++; + return n; +} + +function fullGc() { + gc(); +} + +// The process's peak resident set in KiB (one process: both heaps). +function maxRss() { + return process.resourceUsage().maxRSS; +} + +module.exports = { Ticker, box, call, deferred, remember, recall, hold, held, watch, alive, fullGc, maxRss }; diff --git a/tests/fixtures/node-island/gc-cycles.mjs b/tests/fixtures/node-island/gc-cycles.mjs new file mode 100644 index 000000000..0638a733b --- /dev/null +++ b/tests/fixtures/node-island/gc-cycles.mjs @@ -0,0 +1,105 @@ +// Cross-heap garbage cycles (scope-B NI-8), for node-island-gc.test.ts. +// Each iteration makes and drops one cycle of every shape: a native object +// holding an island emitter whose listener captures it, an island object +// holding a native closure that captures it, a native promise held by an +// island object that it settles with, an island WeakMap entry keyed by a +// native object whose value captures the key, and a native object of a +// shared module paired with an island object. Some cycles stay held +// by one side only (native roots, a native closure, island roots, an +// island WeakRef next to a native root, a pending promise); they must +// survive every collection and keep working. Usage: gc-cycles [gc] +// (gc: force a full V8 collection every 64 iterations). +import { requireModule } from "@scriptc/node"; +import { openSessions } from "./gc-cycles-sessions.mjs"; + +const lib = requireModule("./gc-cycles.cjs"); +const peers = requireModule("./gc-cycles-peers.mjs"); +const n = Number(process.argv[2] ?? "1000"); +const forceGc = process.argv[3] === "gc"; + +class Holder { + constructor(i) { + this.i = i; + this.hits = 0; + this.ticker = new lib.Ticker(); + this.ticker.on("tick", (k) => { + this.hits += k; + }); + } +} + +const nativeKept = []; +const closures = []; +const pending = []; +let sum = 0; +for (let i = 0; i < n; i++) { + // 1. native -> island emitter -> native listener -> native + const h = new Holder(i); + h.ticker.tick(1); + sum += h.hits; + + // 2. island object -> native closure -> island object + const b = lib.box(i); + b.cb = () => b.n + b.payload.length; + sum += lib.call(b); + + // 3. island object -> native promise -> settled with the island object + const o = lib.box(i); + const np = new Promise((resolve) => { + o.cb = () => resolve(o); + }); + o.p = np; + lib.call(o); + sum += (await np).n; + + // 4. island WeakMap: native key -> island value -> native closure -> key + const key = { i }; + const value = lib.box(i); + value.cb = () => key.i; + key.value = value; + lib.remember(key, value); + sum += lib.call(lib.recall(key)); + + // 5. shared-module native session <-> island peer + sum += peers.pair(i, i < 10000 && i % 967 === 0); + + // Some cycles stay reachable from one side only (the same ones for + // every n of at least 10000, so what stays alive does not grow with n). + const keep = i < 10000; + if (keep && i % 997 === 0) nativeKept.push(h); + if (keep && i % 991 === 0) closures.push(() => lib.call(b) + lib.call(key.value)); + if (keep && i % 983 === 0) lib.hold(o); + if (keep && i % 977 === 0) { + nativeKept.push(key); + lib.watch(key); + } + if (keep && i % 971 === 0) { + const d = lib.deferred(); + const ticker = new Holder(-i); + pending.push( + d.promise.then((v) => { + ticker.ticker.tick(v); + return ticker.hits; + }), + ); + lib.hold(d); + } + if (forceGc && i % 64 === 63) lib.fullGc(); +} +lib.fullGc(); +await new Promise((resolve) => setTimeout(resolve, 1)); +lib.fullGc(); + +// Everything held from one side still works. +let check = 0; +for (const k of nativeKept) check += k instanceof Holder ? k.ticker.tick(2) && k.hits : lib.call(k.value); +for (const f of closures) check += f(); +for (const o of lib.held) { + if (o.resolve) o.resolve(3); + else check += (await o.p).n + 1; +} +for (const p of pending) check += await p; +for (const s of openSessions()) check += s.send("again"); +const watched = nativeKept.filter((k) => !(k instanceof Holder)).length; +console.log("sum", sum, "check", check, "watched alive", lib.alive(), "of", watched); +console.error(`peak rss ${lib.maxRss()} KiB`); diff --git a/tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json b/tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json new file mode 100644 index 000000000..10edd65b7 --- /dev/null +++ b/tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 2000 } diff --git a/tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json b/tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json new file mode 100644 index 000000000..99d1bba59 --- /dev/null +++ b/tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 1350, "san": 2250 } diff --git a/tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json b/tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json new file mode 100644 index 000000000..005ea43a2 --- /dev/null +++ b/tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 2200 } diff --git a/tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json b/tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json new file mode 100644 index 000000000..626a48272 --- /dev/null +++ b/tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 1150, "san": 1900 } diff --git a/tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json b/tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json new file mode 100644 index 000000000..ae5c087c8 --- /dev/null +++ b/tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 1550, "san": 2600 } diff --git a/tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json b/tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json new file mode 100644 index 000000000..a18edc536 --- /dev/null +++ b/tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2000 } diff --git a/tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json b/tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json new file mode 100644 index 000000000..603773b07 --- /dev/null +++ b/tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 1200, "san": 2200 } diff --git a/tests/harness/node-island-gc.test.ts b/tests/harness/node-island-gc.test.ts new file mode 100644 index 000000000..1851d5a18 --- /dev/null +++ b/tests/harness/node-island-gc.test.ts @@ -0,0 +1,98 @@ +/* Cross-heap cycle collection (scope-B NI-8) where a differential corpus + * program cannot show it. tests/fixtures/node-island/gc-cycles.mjs makes + * and drops one cross-heap cycle of each shape per iteration and keeps some + * reachable from one side only; the corpus (6890-6895) pins the same shapes + * against Node. + * - The memory and the handles a program holds do not grow with the + * number of cycles it dropped (10k against 100k iterations). + * - Forcing full collections throughout (and, in the sanitized lane, + * under ASan) frees nothing still reachable: the run prints exactly + * what a run with the collector switched off prints, and never trips the + * bridge's use-after-collection check. + * Needs a libnode pack in SCRIPTC_LIBNODE (libnode.ts). */ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; +import { libnodePack } from "./libnode.js"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const fixtures = join(repoRoot, "tests/fixtures/node-island"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-gc"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const skip = libnodePack() === null; + +interface Run { + stdout: string; + stderr: string; + totals: Record; + rss: number; +} + +let binary = ""; + +async function run(args: string[], env: Record = {}): Promise { + const { stdout, stderr } = await execFileAsync(binary, args, { + cwd: fixtures, + env: { ...process.env, SCRIPTC_NODE_ISLANDS_TRACE: "gc", ...env }, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + expect(stderr).not.toMatch(/used after V8 collected|AddressSanitizer|LeakSanitizer/); + const line = /\[node-islands\] gc totals: (.*)/.exec(stderr); + expect(line).not.toBeNull(); + const totals: Record = {}; + for (const m of line![1]!.matchAll(/([a-z ]+?) (\d+(?:\.\d+)?)(?: ms)?(?:,|$)/g)) + totals[m[1]!.trim()] = Number(m[2]); + const rss = Number(/peak rss (\d+) KiB/.exec(stderr)?.[1]); + return { stdout, stderr, totals, rss }; +} + +describe.skipIf(skip)("Node island cross-heap cycles", () => { + beforeAll(async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(join(fixtures, "gc-cycles.mjs"), { + outPath: join(outDir, "gc-cycles"), + outDir, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + binary = result.binaryPath; + }, 300_000); + + test("memory and held handles stay flat as dropped cycles grow", async () => { + const small = await run(["10000"]); + const large = await run(["100000"]); + for (const r of [small, large]) { + expect(r.stdout).toMatch(/^sum \d+ check \d+ watched alive (\d+) of \1\n$/); + // Every major GC was analyzed, and the collector freed nearly every + // dropped cycle (seven representatives and five imports each). + expect(r.totals["analyzed"]).toBe(r.totals["major"]); + } + expect(large.totals["freed exports"]).toBeGreaterThan(0.99 * 7 * 100_000 - 1000); + expect(large.totals["freed imports"]).toBeGreaterThan(0.99 * 5 * 100_000 - 1000); + // What stays alive is what the program keeps, the same for both. + for (const key of ["held exports", "held handles", "representatives", "import entries"]) + expect(large.totals[key], key).toBe(small.totals[key]); + expect(large.totals["held handles"]).toBeLessThan(200); + // Peak memory does not grow with the cycles dropped (ASan's quarantine + // makes the sanitized lane's resident set meaningless). + if (!sanitize) expect(large.rss).toBeLessThan(small.rss * 1.5 + 32 * 1024); + }, 600_000); + + test("forced collections free nothing still reachable from either side", async () => { + const n = sanitize ? "2000" : "6000"; + const off = await run([n, "gc"], { SCRIPTC_NODE_ISLANDS_GC: "off" }); + const on = await run([n, "gc"]); + expect(off.totals["analyzed"]).toBe(0); + expect(on.totals["analyzed"]).toBe(on.totals["major"]); + expect(on.totals["major"]).toBeGreaterThan(20); + expect(on.totals["freed exports"]).toBeGreaterThan(0); + expect(on.stdout).toMatch(/^sum \d+ check \d+ watched alive (\d+) of \1\n$/); + expect(on.stdout).toBe(off.stdout); + }, 600_000); +}); diff --git a/tests/harness/node-island-handles.test.ts b/tests/harness/node-island-handles.test.ts index 264978794..7b6d731e5 100644 --- a/tests/harness/node-island-handles.test.ts +++ b/tests/harness/node-island-handles.test.ts @@ -1,9 +1,9 @@ /* Island object handles (scope-B NI-4) where a differential corpus program * cannot show it: handles to Node objects the program dropped are released, * so the import table does not grow with the work a program does (acyclic - * garbage; cycles across both heaps wait for NI-8). The corpus (6812-6815) - * pins handle semantics against Node. Needs a libnode pack in - * SCRIPTC_LIBNODE (libnode.ts). */ + * garbage; node-island-gc.test.ts covers cycles across both heaps). The + * corpus (6812-6815) pins handle semantics against Node. Needs a libnode + * pack in SCRIPTC_LIBNODE (libnode.ts). */ import { execFile } from "node:child_process"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index f205e8f68..4efb384a1 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -160,7 +160,7 @@ describe.skipIf(skip)("Node islands in-process", () => { const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir }); expect(result.code).toBe(1); expect(result.stderr).toBe( - `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 9)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 10)\n`, ); }); From 3ed13f4c4b72fbc5b60746fa38937a9780afd1dd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 03:59:57 -0500 Subject: [PATCH 004/193] Fix CI on the island stack --- .../6882-node-island-nested-require/main.js.json | 15 ++++++++++++++- tests/harness/package-shims.test.ts | 7 ++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json index 9010bb753..0fa205987 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6882-node-island-nested-require/main.js.json @@ -1,6 +1,19 @@ { "order": [ + "/tests/corpus/6882-node-island-nested-require/spaced.js", "/tests/corpus/6882-node-island-nested-require/main.js" ], - "diags": [] + "diags": [ + { + "code": "SC1010", + "message": "the 'lazykit' module is not supported yet", + "loc": { + "file": "/tests/corpus/6882-node-island-nested-require/main.js", + "start": 1018, + "end": 1045 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] } diff --git a/tests/harness/package-shims.test.ts b/tests/harness/package-shims.test.ts index a21c4e768..676b77e49 100644 --- a/tests/harness/package-shims.test.ts +++ b/tests/harness/package-shims.test.ts @@ -264,17 +264,18 @@ describe("package shims", () => { ); writeFileSync(join(pkg, "index.js"), "exports.version = 6;\n"); writeFileSync( - join(dir, "main.mjs"), + join(dir, "main.mts"), 'import { version } from "undici";\nconsole.log(version);\n', ); - const result = await compile(join(dir, "main.mjs"), { + const result = await compile(join(dir, "main.mts"), { outPath: join(dir, ".scriptc/program"), outDir: join(dir, ".scriptc"), npmStatic: ["undici"], sanitize, }); // The package's JavaScript is not part of the program, so the import - // has no declarations. + // has no declarations. A TypeScript entry requires them; a JavaScript + // entry would load the package in a Node island. expect(result.ok).toBe(false); expect(result.diagnostics.map((d) => d.message).join("\n")).toContain( "Could not find a declaration file for module 'undici'", From 4c9cfc1cc40104d7cca147e0f3e5a96898ff74fb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 12:19:32 -0700 Subject: [PATCH 005/193] Run the deferral test's terminal case under macOS's script(1) BSD script takes the command after the transcript file, needs a stdin that is not a socket, and echoes the EOF it forwards from /dev/null. Co-Authored-By: Claude Opus 5.5 --- tests/harness/node-island-deferral.test.ts | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/harness/node-island-deferral.test.ts b/tests/harness/node-island-deferral.test.ts index 05f8825a1..8b66bf3d1 100644 --- a/tests/harness/node-island-deferral.test.ts +++ b/tests/harness/node-island-deferral.test.ts @@ -5,7 +5,7 @@ * that is a pipe at build time and a terminal when the program runs. The * fixtures are in tests/fixtures/node-island/deferral. Needs a libnode pack * in SCRIPTC_LIBNODE (libnode.ts). */ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -13,6 +13,25 @@ import { describe, expect, test } from "vitest"; import { libnodePack } from "./libnode.js"; const execFileAsync = promisify(execFile); + +/** stdout of `binary` run under a pseudo-terminal by script(1). util-linux's + * takes the command with -c. The BSD one on macOS takes it after the + * transcript file, needs a stdin that is not a socket, and echoes the EOF + * it forwards from /dev/null as ^D and two backspaces. */ +async function underTerminal(binary: string, env: NodeJS.ProcessEnv): Promise { + if (process.platform !== "darwin") + return (await execFileAsync("script", ["-qec", binary, "/dev/null"], { env, encoding: "utf8" })) + .stdout; + const child = spawn("script", ["-q", "/dev/null", binary], { + env, + stdio: ["ignore", "pipe", "inherit"], + }); + let stdout = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk)); + const code = await new Promise((resolve) => child.on("close", resolve)); + expect(code).toBe(0); + return stdout.replace(/\^D\x08\x08/g, ""); +} const repoRoot = join(import.meta.dirname, "../.."); const cli = join(repoRoot, "packages/cli/dist/main.js"); const fixtures = join(repoRoot, "tests/fixtures/node-island/deferral"); @@ -71,10 +90,7 @@ describe.skipIf(skip)("deferred Node island modules", () => { const { binary } = await build("tty"); expect(await run(binary)).toBe("color false\n"); // Under a pseudo-terminal the module sees a terminal, as under Node. - const { stdout } = await execFileAsync("script", ["-qec", binary, "/dev/null"], { - env: { ...process.env, XDG_CACHE_HOME: join(outDir, "cache") }, - encoding: "utf8", - }); - expect(stdout.replace(/\r/g, "")).toBe("color true\n"); + const env = { ...process.env, XDG_CACHE_HOME: join(outDir, "cache") }; + expect((await underTerminal(binary, env)).replace(/\r/g, "")).toBe("color true\n"); }, 300_000); }); From b3decaa4cd6780b2f49ffa864d075122458fa2d0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 14:07:49 -0700 Subject: [PATCH 006/193] Strip script(1)'s EOF echo without a control-character regex Co-Authored-By: Claude Opus 5.5 --- tests/harness/node-island-deferral.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/harness/node-island-deferral.test.ts b/tests/harness/node-island-deferral.test.ts index 8b66bf3d1..c17b847fc 100644 --- a/tests/harness/node-island-deferral.test.ts +++ b/tests/harness/node-island-deferral.test.ts @@ -30,7 +30,7 @@ async function underTerminal(binary: string, env: NodeJS.ProcessEnv): Promise (stdout += chunk)); const code = await new Promise((resolve) => child.on("close", resolve)); expect(code).toBe(0); - return stdout.replace(/\^D\x08\x08/g, ""); + return stdout.replaceAll("^D\b\b", ""); } const repoRoot = join(import.meta.dirname, "../.."); const cli = join(repoRoot, "packages/cli/dist/main.js"); From db2c41910cb2a13b91aa2ec1fa77dd5670f7286b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 06:01:13 -0700 Subject: [PATCH 007/193] Suppress the root certificates' leak under a stripped libnode The libnode-packs workflow's packs are stripped, so the certificate loading threads' Node frames have no names and the node::crypto suppressions do not match. Each certificate is parsed through libnode's OpenSSL PEM reader, which the runtime never links. Co-Authored-By: Claude Opus 5.5 --- tests/harness/lsan.supp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp index a0fe1c5ae..cad4b08e9 100644 --- a/tests/harness/lsan.supp +++ b/tests/harness/lsan.supp @@ -20,6 +20,11 @@ leak:node::crypto::GetSystemStoreCACertificates leak:node::crypto::GetExtraCACertificates leak:node::crypto::GetOpenSSLSystemCertificates leak:node::crypto::LoadCertsFromBIO +# The same certificates under a stripped libnode (the packs the +# libnode-packs workflow builds), where the loading threads' Node frames +# have no names: each one is parsed through libnode's OpenSSL PEM reader. +# The runtime has no OpenSSL of its own, so only libnode reaches it. +leak:PEM_ASN1_read_bio # The certificates' lazily decoded keys, cached in them when a root store # is built. leak:node::crypto::NewRootCertStore From d06155048e2a8e221b374391f8a9f23f37c50ae2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:06:41 -0700 Subject: [PATCH 008/193] Only bare binding getters are pure; compare getter exports for mutation A member read in an export getter (a.b or a[b]) can invoke another accessor that runs code on every read, so the recorder now accepts only a bare identifier as a plain binding getter. Everything else keeps its runtime read and is not snapshotted. The mutation check that makes an earlier island module eager copied only data properties of a module value, so an object exported through a binding getter could stay stale when a later load changed it. The shape now reads binding getters the same way the snapshot does, and compares them by reading again, so those modules no longer defer. Corpus fixtures pin both: a getter that reads a member counts every read, and a later load that mutates a getter-exported object keeps both modules eager. Co-Authored-By: Claude Opus 5.5 --- .../src/node-islands/probe-recorder.ts | 36 ++++++++++++------- .../deferral/getter-member/main.ts | 3 ++ .../node_modules/counter-pkg/index.d.ts | 2 ++ .../node_modules/counter-pkg/index.js | 7 ++++ .../node_modules/counter-pkg/package.json | 1 + .../deferral/getter-member/package.json | 1 + .../deferral/mutate-getter/main.ts | 4 +++ .../node_modules/pkg-a/index.d.ts | 1 + .../mutate-getter/node_modules/pkg-a/index.js | 4 +++ .../node_modules/pkg-a/package.json | 1 + .../node_modules/pkg-b/index.d.ts | 1 + .../mutate-getter/node_modules/pkg-b/index.js | 4 +++ .../node_modules/pkg-b/package.json | 1 + .../deferral/mutate-getter/package.json | 1 + tests/harness/node-island-deferral.test.ts | 16 +++++++++ 15 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/node-island/deferral/getter-member/main.ts create mode 100644 tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.d.ts create mode 100644 tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.js create mode 100644 tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/package.json create mode 100644 tests/fixtures/node-island/deferral/getter-member/package.json create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/main.ts create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.d.ts create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.js create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/package.json create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.d.ts create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.js create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/package.json create mode 100644 tests/fixtures/node-island/deferral/mutate-getter/package.json diff --git a/packages/compiler/src/node-islands/probe-recorder.ts b/packages/compiler/src/node-islands/probe-recorder.ts index 1299c183f..499de3593 100644 --- a/packages/compiler/src/node-islands/probe-recorder.ts +++ b/packages/compiler/src/node-islands/probe-recorder.ts @@ -80,9 +80,11 @@ function internalCaller() { const fnToString = Function.prototype.toString; const hasOwn = Object.prototype.hasOwnProperty; // A getter that only reads a binding (esbuild's and tsc's export getters): -// reading it again reads the same binding. Any other getter can run code -// on every read, so its member is not snapshotted. -const BINDING_GETTER = /^(?:\(\)\s*=>\s*[\w$.]+|function\s*[\w$]*\s*\(\)\s*\{\s*return\s+[\w$.]+(?:\[[\w$]+\])?\s*;?\s*\}|get\s+[\w$]+\s*\(\)\s*\{\s*return\s+[\w$.]+\s*;?\s*\})$/; +// reading it again reads the same binding. A member read ('a.b', 'a[b]') +// can invoke another accessor that runs code on every read, so only a bare +// identifier qualifies. Any other getter can also run code on every read, +// so its member is not snapshotted. +const BINDING_GETTER = /^(?:\(\)\s*=>\s*[A-Za-z_$][\w$]*|function\s*[\w$]*\s*\(\)\s*\{\s*return\s+[A-Za-z_$][\w$]*\s*;?\s*\}|get\s+[\w$]+\s*\(\)\s*\{\s*return\s+[A-Za-z_$][\w$]*\s*;?\s*\})$/; function bindingGetter(fn) { let source = ""; try { source = fnToString.call(fn); } catch { return false; } @@ -463,20 +465,31 @@ exports.probe = function probe(input) { return [name, m]; }); + // What a member of a module value reads now: a data property's value, or + // a binding getter's (esbuild's and tsc's export getters). Any other + // accessor can run code, so it is not part of a shape. + const MISSING = {}; + const memberValue = (o, k) => { + const d = Object.getOwnPropertyDescriptor(o, k); + if (d === undefined) return MISSING; + if ("value" in d) return d.value; + if (typeof d.get !== "function" || !bindingGetter(d.get)) return MISSING; + try { return d.get.call(o); } catch { return MISSING; } + }; + // A module's exported values right after its load, at the depth the // snapshot covers (the module value's members, and theirs for objects). const exportShape = (v) => { const shape = []; if (v === null || (typeof v !== "object" && typeof v !== "function")) return shape; for (const k of Reflect.ownKeys(v)) { - const d = Object.getOwnPropertyDescriptor(v, k); - if (d === undefined || !("value" in d)) continue; - shape.push([v, k, d.value]); - const m = d.value; + const m = memberValue(v, k); + if (m === MISSING) continue; + shape.push([v, k, m]); if (m !== null && typeof m === "object") for (const k2 of Reflect.ownKeys(m)) { - const d2 = Object.getOwnPropertyDescriptor(m, k2); - if (d2 !== undefined && "value" in d2) shape.push([m, k2, d2.value]); + const m2 = memberValue(m, k2); + if (m2 !== MISSING) shape.push([m, k2, m2]); } } return shape; @@ -537,10 +550,7 @@ exports.probe = function probe(input) { // earlier snapshot no longer holds, and this load's change has to happen // where the program imports it). for (const [earlier, shape] of shapes) { - const changed = shape.some(([o, k, v]) => { - const d = Object.getOwnPropertyDescriptor(o, k); - return d === undefined || !("value" in d) || !Object.is(d.value, v); - }); + const changed = shape.some(([o, k, v]) => !Object.is(memberValue(o, k), v)); if (!changed) continue; delete earlier.root; earlier.reason = "a later island module's load changes its exports"; diff --git a/tests/fixtures/node-island/deferral/getter-member/main.ts b/tests/fixtures/node-island/deferral/getter-member/main.ts new file mode 100644 index 000000000..96223673c --- /dev/null +++ b/tests/fixtures/node-island/deferral/getter-member/main.ts @@ -0,0 +1,3 @@ +import counter from "counter-pkg"; + +console.log(counter.label, counter.count, counter.count); diff --git a/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.d.ts b/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.d.ts new file mode 100644 index 000000000..82afbe16d --- /dev/null +++ b/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.d.ts @@ -0,0 +1,2 @@ +declare const counter: { readonly count: number; readonly label: string }; +export default counter; diff --git a/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.js b/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.js new file mode 100644 index 000000000..8ad752913 --- /dev/null +++ b/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/index.js @@ -0,0 +1,7 @@ +"use strict"; +let reads = 0; +// The export getter reads a member, not a binding: that member's getter +// counts the read, so it is not a plain export getter. +const state = { get count() { return ++reads; } }; +exports.label = "counter"; +Object.defineProperty(exports, "count", { enumerable: true, get: () => state.count }); diff --git a/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/package.json b/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/package.json new file mode 100644 index 000000000..50ce07f31 --- /dev/null +++ b/tests/fixtures/node-island/deferral/getter-member/node_modules/counter-pkg/package.json @@ -0,0 +1 @@ +{ "name": "counter-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/node-island/deferral/getter-member/package.json b/tests/fixtures/node-island/deferral/getter-member/package.json new file mode 100644 index 000000000..6d5d7c46b --- /dev/null +++ b/tests/fixtures/node-island/deferral/getter-member/package.json @@ -0,0 +1 @@ +{ "name": "deferral-getter-member", "private": true, "type": "module", "scriptc": { "packages": { "counter-pkg": "node" } } } diff --git a/tests/fixtures/node-island/deferral/mutate-getter/main.ts b/tests/fixtures/node-island/deferral/mutate-getter/main.ts new file mode 100644 index 000000000..f7dbae8e2 --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/main.ts @@ -0,0 +1,4 @@ +import { config } from "pkg-a"; +import { ok } from "pkg-b"; + +console.log(config.mode, ok); diff --git a/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.d.ts b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.d.ts new file mode 100644 index 000000000..8b403d629 --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.d.ts @@ -0,0 +1 @@ +export declare const config: { mode: string }; diff --git a/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.js b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.js new file mode 100644 index 000000000..c46d72b3a --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/index.js @@ -0,0 +1,4 @@ +"use strict"; +// pkg-a exports through a binding getter over its own object. +const config = { mode: "initial" }; +Object.defineProperty(exports, "config", { enumerable: true, get: () => config }); diff --git a/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/package.json b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/package.json new file mode 100644 index 000000000..e0270f674 --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-a/package.json @@ -0,0 +1 @@ +{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.d.ts b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.d.ts new file mode 100644 index 000000000..f29ace298 --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.d.ts @@ -0,0 +1 @@ +export declare const ok: boolean; diff --git a/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.js b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.js new file mode 100644 index 000000000..749c51d3c --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/index.js @@ -0,0 +1,4 @@ +"use strict"; +// Its load changes what pkg-a exports, through pkg-a's binding getter. +require("pkg-a").config.mode = "set by pkg-b"; +exports.ok = true; diff --git a/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/package.json b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/package.json new file mode 100644 index 000000000..ea2784527 --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/node_modules/pkg-b/package.json @@ -0,0 +1 @@ +{ "name": "pkg-b", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "pkg-a": "1.0.0" } } diff --git a/tests/fixtures/node-island/deferral/mutate-getter/package.json b/tests/fixtures/node-island/deferral/mutate-getter/package.json new file mode 100644 index 000000000..0416d97bc --- /dev/null +++ b/tests/fixtures/node-island/deferral/mutate-getter/package.json @@ -0,0 +1 @@ +{ "name": "deferral-mutate-getter", "private": true, "type": "module", "scriptc": { "packages": { "pkg-a": "node", "pkg-b": { "run": "node", "evaluate": "lazy" } } } } diff --git a/tests/harness/node-island-deferral.test.ts b/tests/harness/node-island-deferral.test.ts index c17b847fc..93dce44cb 100644 --- a/tests/harness/node-island-deferral.test.ts +++ b/tests/harness/node-island-deferral.test.ts @@ -75,11 +75,27 @@ describe.skipIf(skip)("deferred Node island modules", () => { expect(await run(binary)).toBe("set by pkg-b true\n"); }, 300_000); + test("a load that changes an object exported through a getter defers neither", async () => { + const { binary, trace } = await build("mutate-getter"); + expect(trace.some((l) => /eager .*pkg-a.*a later island module's load changes/.test(l))).toBe( + true, + ); + expect( + trace.some((l) => /eager .*pkg-b.*changes another island module's exports/.test(l)), + ).toBe(true); + expect(await run(binary)).toBe("set by pkg-b true\n"); + }, 300_000); + test("an export getter that runs code runs on every read", async () => { const { binary } = await build("getter"); expect(await run(binary)).toBe("counter 1 2\n"); }, 300_000); + test("an export getter that reads a member runs on every read", async () => { + const { binary } = await build("getter-member"); + expect(await run(binary)).toBe("counter 1 2\n"); + }, 300_000); + test("a package file missing when the build probes keeps the module eager", async () => { const { binary, trace } = await build("missing-file"); expect(trace.some((l) => /eager .*probe-pkg.*existsSync/.test(l))).toBe(true); From 11918b4d93598a4551d1a0051949fc87566be7cc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:06:49 -0700 Subject: [PATCH 009/193] Treat the worker-startup ordering program as a race g06 races a Worker's startup against a 300 ms busy timer. One macOS x64 run had Node's worker start after the busy loop (h-sync first) while the native runs had it before (i-worker first), the same timing dependence that 1d94942bf marked for g01, p10 and s06. The program now has to produce the lines of one of Node's outputs, in any order, like the other race programs. Co-Authored-By: Claude Opus 5.5 --- tests/corpus-node-islands-order/g06-worker/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/corpus-node-islands-order/g06-worker/main.ts b/tests/corpus-node-islands-order/g06-worker/main.ts index 5ee47a294..fd7f4725a 100644 --- a/tests/corpus-node-islands-order/g06-worker/main.ts +++ b/tests/corpus-node-islands-order/g06-worker/main.ts @@ -1,4 +1,5 @@ // @requires-libnode +// @order: race // X { T 0 { W { L i-worker } } } T 2 { B 300 L h-sync } import { callSync } from "@scriptc/node"; import { readFile, writeSync } from "node:fs"; From 78fc4be1b4fa6ce0fdf785063ca8394ca2441276 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:39:16 -0700 Subject: [PATCH 010/193] Document the event-order limitation between native code and islands Native timers and an island's Node timers run on the hosted libuv loop but are not merged into one order, so callbacks from the two sides can run in a different order than in Node and the order can vary between runs. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 2 ++ .../event-order-across-native-code-and-islands.mdx | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 88b2ca5fc..cf6114037 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -172,6 +172,8 @@ title: "Limitations" ../limitations/runtime-differences-from-node-js.mdx +../limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx + ### Array bounds and runtime errors ../limitations/array-bounds-and-runtime-errors.mdx diff --git a/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx b/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx new file mode 100644 index 000000000..49b748f65 --- /dev/null +++ b/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx @@ -0,0 +1,3 @@ +- **Event order across native code and islands:** Timers, worker output, and other macrotasks from the program and from JavaScript it runs in a Node island share one event loop under Node.js, so their order is fixed. + A compiled program runs both on the event loop it hosts for Node.js, but it does not merge them into one order, so callbacks from the two sides can run in a different order than under Node.js, and the order can vary between runs. + Only programs that depend on the ordering between native and island callbacks are affected. From 538b78f59d2f3b5a9872b23239fcc7c543b46fc9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 08:49:39 -0700 Subject: [PATCH 011/193] Leave the JS-widened binding prepass set unjournaled analyzeJsWidenedBindings computes jsWidenedBindings for the whole program before lowering begins, so the journal test classifies it as unjournaled rather than a unit collection. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lowerer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ca91306bf..d26e02a0f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4396,6 +4396,9 @@ export class Lowerer { "readsExitCode", "requestInitPropMutatedSyms", "classProtocols", + // The whole-program prepass analyzeJsWidenedBindings computes this set + // before lowering begins: a pure function of the program's modules. + "jsWidenedBindings", "cjsSplitRequireBindings", "cjsWholeExportSymbols", "cjsLiveMemberExports", From e7c83477dc9e36157337624d53db7ab8a68cd124 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:56:59 -0700 Subject: [PATCH 012/193] Suppress OpenSSL's default-verify-path store in the sanitized lane 6883-node-island-builtin-handles builds a Headers inside an island, undici loads node:tls for the builtin classes it backs, and OpenSSL parses the system CA bundle through PEM/X509. That state is freed only by OPENSSL_cleanup at exit, which NI-7c keeps off, and the stacks carry no node::crypto frame, so the existing entries cannot match. Name OpenSSL's allocator entry points, as the file already does for the rest of libnode's process-lifetime state. Co-Authored-By: Claude Opus 5.5 --- tests/harness/lsan.supp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp index a0fe1c5ae..ba2af663f 100644 --- a/tests/harness/lsan.supp +++ b/tests/harness/lsan.supp @@ -23,3 +23,12 @@ leak:node::crypto::LoadCertsFromBIO # The certificates' lazily decoded keys, cached in them when a root store # is built. leak:node::crypto::NewRootCertStore +# The same policy covers OpenSSL's own default-verify-path store, which a +# TLS context created inside an island loads on its own: undici needs TLS +# for the builtin classes it backs (Headers, Request, Response), and OpenSSL +# then parses the system CA bundle through PEM/X509 with no node::crypto +# frame in the stack, so the entries above cannot match. That state is +# likewise freed only by OPENSSL_cleanup at exit, which NI-7c keeps off, so +# it is named here by OpenSSL's allocator entry points instead. +leak:CRYPTO_malloc +leak:CRYPTO_zalloc From 7a7fd8ce1db5a4e1f617b9d1d0d2622c8208813e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 19:45:36 -0500 Subject: [PATCH 013/193] Match Node's node:path behavior from Node 24's path tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit path.posix, path.win32 and the bare path module are one module object each, the handle process.getBuiltinModule answers, so require('path/posix'), path.posix and the target platform's bare path compare as Node's do, a namespace beside other values in one slot is untyped, and path[family] reads that object. Functions read through them take checked arguments: each string parameter passes Node's validateString under its own name (basename's suffix first), resolve validates paths[i] only as far as its last-first walk reads them, parse answers an untyped { root, dir, base, ext, name }, format follows _format over any object, and toNamespacedPath answers a non-string as it is. A JavaScript call whose arguments are not strings, or omit one, goes through the same checks. Typed slots keep the typed function values. path.win32.relative lowercases both resolved paths with String.prototype.toLowerCase and walks them by UTF-16 unit, comparing segments instead when lowercasing changed a length, as Node does. It folded ASCII only, so a path with İ or Ⱥ in a different case gave a longer relative path. The function moves into its own runtime unit, scr_path_win32_relative.c, which links with the regex unit whose case mapping it uses. scr_path.c stays free of it, so library and cross-target builds that link the path functions without the regex unit still link. A JavaScript binding whose inferred type holds a union of functions and arrays (a test table of [fn, cases] rows, or one destructured row) takes the checked-dynamic kind that dynFallbackType gives the unmappable spellings of such tables. Its rows keep their functions' names, and for-of, forEach and destructuring read the items as untyped values. TypeScript bindings keep their typed layout. --- .../limitations/builtin-function-values.mdx | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 6 + .../compiler/src/backend/native/executable.ts | 8 +- .../compiler/src/backend/native/library.ts | 2 +- .../lowering/builtins/module-calls.ts | 5 + .../frontend/lowering/expressions/binary.ts | 21 +- .../lowering/expressions/object-literals.ts | 4 +- .../src/frontend/lowering/lower-assert.ts | 14 +- .../frontend/lowering/lower-builtin-values.ts | 197 +++++++++++- .../src/frontend/lowering/lower-calls.ts | 19 +- .../compiler/src/frontend/lowering/lowerer.ts | 69 ++++- .../lowering/module-namespace-elements.ts | 14 + .../src/frontend/lowering/strict-equality.ts | 16 + .../src/frontend/lowering/surfaces.ts | 111 +++++-- packages/compiler/src/frontend/type-mapper.ts | 12 + packages/compiler/src/ir/builtin-effects.ts | 5 + .../compiler/src/ir/builtin-signatures.ts | 8 + packages/compiler/src/ir/runtime-features.ts | 9 + .../test/process-builtin-values.test.ts | 4 +- .../corpus/js-function-array-tables.cjs.json | 6 + .../corpus/path-function-values.cjs.json | 6 + .../tests/corpus/path-untyped-calls.cjs.json | 6 + .../corpus/path-win32-relative-case.cjs.json | 6 + .../runtime-pack-common/library-matrix.mjs | 1 + .../runtime-pack-matrix.mjs | 2 + packages/runtime/src/scr_builtin_values.c | 283 ++++++++++++++++++ packages/runtime/src/scr_dyn.h | 6 + packages/runtime/src/scr_path.c | 115 +------ packages/runtime/src/scr_path.h | 14 + .../runtime/src/scr_path_win32_relative.c | 169 +++++++++++ packages/runtime/test/path.test.ts | 10 + tests/corpus/js-function-array-tables.cjs | 24 ++ tests/corpus/path-function-values.cjs | 60 ++++ tests/corpus/path-untyped-calls.cjs | 39 +++ tests/corpus/path-win32-relative-case.cjs | 22 ++ 35 files changed, 1123 insertions(+), 171 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-array-tables.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-function-values.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-untyped-calls.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-win32-relative-case.cjs.json create mode 100644 packages/runtime/src/scr_path_win32_relative.c create mode 100644 tests/corpus/js-function-array-tables.cjs create mode 100644 tests/corpus/path-function-values.cjs create mode 100644 tests/corpus/path-untyped-calls.cjs create mode 100644 tests/corpus/path-win32-relative-case.cjs diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index 96c48375c..d650eef3f 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -1,6 +1,7 @@ Some builtins can be stored and passed as functions: - Lowered `node:path` functions across the bare, POSIX, and win32 modules, including optional `basename` and variadic `join`/`resolve`. + Called through an untyped value, they validate their arguments with Node's `ERR_INVALID_ARG_TYPE`. - Supported zero-argument `node:os` functions and `querystring.escape`/`unescape`. - `util.isDeepStrictEqual`, `styleText`, `stripVTControlCharacters`, `toUSVString`, `inherits`, `deprecate`, `callbackify`, `debuglog`, and `debug`. - `fs.existsSync`, `unlinkSync`, `chmodSync`, `chownSync`, `renameSync`, and `closeSync`, and `fs/promises.unlink`, `chmod`, and `rename`. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02479a922..00b72b071 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -440,6 +440,12 @@ export const LIB_FN_SYMS: Record = { "fsp.globError": "scr_fsp_glob_error", "fs.realpathSync": "scr_fs_realpath", "fs.realpathValue": "scr_fs_realpath_value", + "dyn.validateString": "scr_value_string", + "dyn.validateStrings": "scr_value_strings", + "path.resolveValue": "scr_path_resolve_value", + "path.parseValue": "scr_path_parse_value", + "path.formatValue": "scr_path_format_value", + "path.toNamespacedValue": "scr_path_to_namespaced_value", "fs.realpathNativeSync": "scr_fs_realpath_promise", "fs.unlinkSync": "scr_fs_unlink", "fs.chmodSync": "scr_fs_chmod", diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..80e72e0c4 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -169,9 +169,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ // SCR_MUSL flag and this target-selected TU travel together. unit((u) => isMuslTarget(u.driver), "scr_musl.c"), // scr_glob.c (path.matchesGlob) compiles its segments through the - // regex unit and scr_idna.c (node:url's IDNA) normalizes through it, - // so both ride the same switch; section GC drops them from binaries - // that never use them. + // regex unit, scr_idna.c (node:url's IDNA) normalizes through it and + // scr_path_win32_relative.c lowercases through its case mapping, so all + // three ride the same switch; section GC drops them from binaries that + // never use them. unitArgs((u) => u.regex ? [ @@ -180,6 +181,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ u.rt(join(u.rtDir, "scr_regex.c")), u.rt(join(u.rtDir, "scr_glob.c")), u.rt(join(u.rtDir, "scr_idna.c")), + u.rt(join(u.rtDir, "scr_path_win32_relative.c")), ...u.lreObjects, ] : [], diff --git a/packages/compiler/src/backend/native/library.ts b/packages/compiler/src/backend/native/library.ts index 3cf4afa53..3585ab6a6 100644 --- a/packages/compiler/src/backend/native/library.ts +++ b/packages/compiler/src/backend/native/library.ts @@ -104,7 +104,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise // Zig's musl sysroot does not provide arc4random_buf. Keep the fallback // inside the archive so library embedders need no extra system library. ...(isMuslTarget(driver) ? ["scr_musl.c"] : []), - ...(regex ? ["scr_regex.c", "scr_glob.c"] : []), + ...(regex ? ["scr_regex.c", "scr_glob.c", "scr_path_win32_relative.c"] : []), ...(opts.assert || regex || opts.symbol ? ["scr_assert.c"] : []), ...(opts.inspect ? ["scr_inspect.c", "scr_console_native.c"] : []), ...(opts.symbol ? ["scr_symbol.c"] : []), diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index bf5223d2f..8733804af 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -32,6 +32,7 @@ import { lowerUtilTypeCall } from "./util-types.js"; import { InternalCompilerError } from "../../../errors.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; +import { jsCheckedBuiltinCall } from "../lower-builtin-values.js"; import { type Lowerer } from "../lowerer.js"; import { type BuiltinModuleFn } from "../surfaces.js"; import { type ParamShape } from "../call-signatures.js"; @@ -67,6 +68,10 @@ export function lowerBuiltinModuleCall( ); if (fallible) return fallible; checkFsUrlPaths(lowerer, expr, bi); + { + const checked = jsCheckedBuiltinCall(lowerer, expr, fn, `${bi.module}.${bi.member}`, loc); + if (checked) return checked; + } if (bi.module === "util/types") { return lowerUtilTypeCall(lowerer, expr, bi, loc, name); } diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..0c19143ad 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -61,7 +61,7 @@ import { classToStringConversion, classUnionToString } from "../class-to-primiti import { isPerfEntryType, perfEntryTag } from "../builtins/performance.js"; import { isX509Type, x509ToString } from "../builtins/x509.js"; import { guardNullProto, nullProtoRecordShape } from "../null-prototype.js"; -import { numLit, varRef } from "../../../ir/build.js"; +import { boolLit, numLit, varRef } from "../../../ir/build.js"; import { lowerUrlAssignment } from "../builtins/url.js"; import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; import { lowerShortCircuitAssignment } from "./nullish-assignment.js"; @@ -81,7 +81,7 @@ import { runtimeConstructorReceiver, } from "../builtins/runtime-constructor.js"; import { tryLowerIndexedComparison } from "../indexed-comparison.js"; -import { lowerUnionEquality, tagEqualityMayMissAlias } from "../strict-equality.js"; +import { lowerUnionEquality, sameNamespace, tagEqualityMayMissAlias } from "../strict-equality.js"; import { withUnitArm } from "../../type-mapper.js"; import { npmStaticPackageOfPath } from "../../npm-static.js"; import { lowerWebAssemblyTypeof } from "../builtins/webassembly.js"; @@ -1613,9 +1613,11 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const shape = type.kind === "record" ? lowerer.shapes.get(type.shapeId) : undefined; return !!shape && (shape.fields.length > 0 || shape.indexValue !== undefined); }; + // A builtin function boxes as its checked twin, the value its + // module handle answers, so the two compare equal. const boxed: IrExpr = { kind: "dynFrom", - value: scalarSide, + value: lowerer.checkedBuiltinValue(scalarSide), type: DYN, loc: scalarSide.loc, ...(reference.kind === "record" || @@ -1634,13 +1636,16 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; } - // EventEmitter or a stream class against an untyped value in - // JavaScript: the untyped value can hold the class's untyped - // constructor (coerceToExpected), which compares by identity. + // EventEmitter, a stream class or a node:path family against an + // untyped value in JavaScript: the untyped value can hold the class's + // untyped constructor or the module object (coerceToExpected), which + // compare by identity. if ( dynSide.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()) && - ((scalarSide.type.kind === "moduleNs" && scalarSide.type.moduleId === "builtin:events") || + ((scalarSide.type.kind === "moduleNs" && + (scalarSide.type.moduleId === "builtin:events" || + scalarSide.type.moduleId.startsWith("builtin:path"))) || (scalarSide.kind === "classRef" && builtinClassKind(scalarSide.className) !== null)) ) { const boxed = lowerer.coerceToExpected(scalarSide, DYN); @@ -2584,6 +2589,8 @@ export function lowerStrictEquality( }; } if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { + if (idLeft.kind === "moduleNsRef" && idRight.kind === "moduleNsRef") + return boolLit(sameNamespace(lowerer, idLeft.moduleId, idRight.moduleId) !== negated, loc); return { kind: "bin", op: negated ? "!==" : "===", diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 9699a4fb9..9156c0741 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1,7 +1,7 @@ import { keyedHelperName } from "../stable-names.js"; import { dateJsonType, isDateJsonSource } from "../builtins/dates.js"; import { dynUndefinedExpr, numLit, strLit, varRef } from "../../../ir/build.js"; -import { pathModuleValue } from "../lower-builtin-values.js"; +import { pathModuleObject } from "../lower-builtin-values.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; import { @@ -307,7 +307,7 @@ export function lowerDynObjectLiteral( const builtin = lowerer.builtinNamespaceModuleOf(prop.expression); const raw = builtin === "path" || builtin === "path/posix" || builtin === "path/win32" - ? pathModuleValue(lowerer, builtin === "path" ? "path/posix" : builtin, locOf(prop)) + ? pathModuleObject(lowerer, builtin === "path" ? "path/posix" : builtin, locOf(prop)) : !boxValue && conditionalArms && (conditionalSpreadOf(prop.expression) !== null || diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index 02faa7ade..704314eaf 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1290,9 +1290,21 @@ function lowerAssertEqual( ?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ?? false))) && lowerer.dynConvertible(e.type); - if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) { + if ( + a.type.kind === "dyn" || + b.type.kind === "dyn" || + a.type.kind === "moduleNs" || + b.type.kind === "moduleNs" || + (instance(a) && instance(b)) + ) { const box = (e: IrExpr, node: ts.Expression): IrExpr => { if (e.type.kind === "dyn") return e; + // A builtin module namespace crosses as its module object, whose + // identity is the module's (`assert.strictEqual(path, path.posix)`). + if (e.type.kind === "moduleNs") { + const value = lowerer.coerceToExpected(e, DYN); + if (value.type.kind === "dyn") return value; + } const k = e.type.kind; const ok = e.kind === "unitLit" || diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index d59b234d3..2d9eb8964 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -10,9 +10,15 @@ import { type IrExpr, type IrLibFn, type IrStmt, + type IrType, type SrcLoc, + typeEquals, + typeKey, } from "../../ir/ir.js"; -import { strLit, varRef } from "../../ir/build.js"; +import { boolLit, dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; +import { isJsSourceFile } from "../program.js"; +import { funcTypeFromParamShapes, type ParamShape } from "./call-signatures.js"; +import { keyedHelperName } from "./stable-names.js"; import type { Lowerer } from "./lowerer.js"; import { BUILTIN_MODULE_CONSTS, @@ -22,6 +28,8 @@ import { builtinModuleConstOf, builtinModulesArrayLit, stdlibGlobalNameOf, + type BuiltinModuleFn, + type BuiltinValueParam, } from "./surfaces.js"; import { CRYPTO_CONSTANTS } from "./crypto-tables.js"; @@ -425,14 +433,24 @@ const MODULES = ["path/posix", "path/win32", "os", "worker_threads", "util/types const box = (value: IrExpr): IrExpr => value.type.kind === "dyn" ? value : { kind: "dynFrom", value, type: DYN, loc: value.loc }; +/** `path.posix`, `path.win32` and the bare module as untyped values: the + * same module handle process.getBuiltinModule answers, so each family is + * one object (`require("path/posix") === require("path").posix`) whose + * members are the interned function values. */ export function pathModuleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { + return moduleValue(lowerer, module, loc); +} + +/** A path family's members as a fresh object, for spreading (`{ ...path }` + * copies the enumerable members). */ +export function pathModuleObject(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { const fields: { key: IrExpr; value: IrExpr }[] = []; const add = (member: string, value: IrExpr): void => { fields.push({ key: strLit(member, loc), value: box(value) }); }; for (const member of Object.keys(builtinModuleFns()[module] ?? {})) { const value = lowerer.lowerBuiltinCallableValue({ module, member }, loc); - if (value) add(member, value); + if (value) add(member, lowerer.checkedBuiltinValue(value)); } for (const member of Object.keys(BUILTIN_MODULE_CONSTS[module] ?? {})) { const value = builtinModuleConstOf(lowerer, module, member); @@ -514,7 +532,7 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { }; for (const member of Object.keys(builtinModuleFns()[module] ?? {})) { const callable = lowerer.lowerBuiltinCallableValue({ module, member }, loc); - if (callable) add(member, callable); + if (callable) add(member, lowerer.checkedBuiltinValue(callable)); } for (const member of Object.keys(BUILTIN_MODULE_CONSTS[module] ?? {})) { const worker = module === "worker_threads" ? lowerWorkerMetadata(member, loc) : null; @@ -525,20 +543,27 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { if (module.startsWith("path/")) { add("posix", moduleValue(lowerer, "path/posix", loc)); add("win32", moduleValue(lowerer, "path/win32", loc)); + // Node's deprecated alias of toNamespacedPath. + const alias = lowerer.lowerBuiltinCallableValue({ module, member: "toNamespacedPath" }, loc); + if (alias) add("_makeLong", lowerer.checkedBuiltinValue(alias)); } if (module === "worker_threads") { for (const member of ["isInternalThread", "parentPort", "workerData"]) add(member, lowerWorkerMetadata(member, loc)!); } + // A path family is the whole of Node's object, so another key is + // absent there too. body.push({ kind: "return", - value: { - kind: "libCall", - fn: "process.builtinUnsupported", - args: [strLit(module, loc), key], - type: DYN, - loc, - }, + value: module.startsWith("path/") + ? dynUndefinedExpr(loc) + : { + kind: "libCall", + fn: "process.builtinUnsupported", + args: [strLit(module, loc), key], + type: DYN, + loc, + }, loc, }); lowerer.liftedFns.push({ @@ -711,3 +736,155 @@ export function builtinConstantsObject(lowerer: Lowerer, loc: SrcLoc): IrExpr { }; return { kind: "libCall", fn: "constants.object", args: [box(table)], type: DYN, loc }; } + +/** The untyped-caller twin of a builtin function value whose parameters + * carry Node's validator names (BuiltinValueParam.name): every argument + * arrives as a checked-dynamic value. With a `checked` entry point the + * arguments reach it as given, followed by its constants; otherwise string + * parameters pass through validateString in Node's order (`valueOrder`) + * before the direct call's entry point. One interned adapter per entry, + * so each read of the value is the same closure. */ +export function checkedNativeCallableValue( + lowerer: Lowerer, + fn: BuiltinModuleFn, + display: string, + loc: SrcLoc, +): IrExpr { + const valueParams = fn.valueParams ?? []; + const callee = fn.checked?.fn ?? fn.valueFn ?? fn.fn; + const result = fn.checked ? (fn.checked.result ?? fn.result) : fn.result; + const raw = fn.checked !== undefined; + const shapes: ParamShape[] = valueParams.map((param): ParamShape => ({ + mode: param.mode === "rest" ? "dynRest" : param.mode === "optional" ? "omittable" : "required", + type: DYN, + })); + const funcType = funcTypeFromParamShapes(shapes, result); + const key = `checked:${callee}:${JSON.stringify(fn.checked?.args ?? [])}:${typeKey(funcType)}`; + let fnName = lowerer.builtinCallableValueFns.get(key); + if (!fnName) { + fnName = keyedHelperName(lowerer, `%builtin.value.${callee}`, key); + lowerer.builtinCallableValueFns.set(key, fnName); + lowerer.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1)); + const params = valueParams.map((_, index) => ({ + localId: `arg.${index}`, + name: `arg${index}`, + type: DYN, + })); + const locals = params.map((param) => ({ + id: param.localId, + name: param.name, + type: param.type as IrType, + mutable: false, + })); + // validateString under Node's name; an omitted optional argument (or + // undefined) takes its default without a check, as Node's + // `if (suffix !== undefined)` does. + const checked = (param: BuiltinValueParam, index: number): IrExpr => { + const ref = varRef(`arg.${index}`, DYN, loc); + const name = strLit(param.name ?? `arg${index}`, loc); + if (param.mode === "rest") { + return { + kind: "libCall", + fn: "dyn.validateStrings", + args: [ref, name], + type: arrayOf(STRING), + loc, + }; + } + const value: IrExpr = { + kind: "libCall", + fn: "dyn.validateString", + args: [ref, name], + type: STRING, + loc, + }; + if (param.mode !== "optional" || typeof param.defaultValue !== "string") return value; + return { + kind: "ternary", + cond: { kind: "dynTest", test: "undefined", value: ref, type: BOOL, loc }, + then: strLit(param.defaultValue, loc), + else_: value, + type: STRING, + loc, + }; + }; + const body: IrStmt[] = []; + const args: IrExpr[] = valueParams.map((param, index) => + raw || param.type.kind === "dyn" + ? varRef(`arg.${index}`, DYN, loc) + : varRef(`checked.${index}`, param.mode === "rest" ? arrayOf(STRING) : STRING, loc), + ); + // Validate in Node's order, then call with the arguments in place. + for (const index of fn.valueOrder ?? valueParams.map((_, i) => i)) { + const param = valueParams[index]!; + if (raw || param.type.kind === "dyn") continue; + const init = checked(param, index); + locals.push({ + id: `checked.${index}`, + name: `checked${index}`, + type: init.type, + mutable: false, + }); + body.push({ kind: "varDecl", localId: `checked.${index}`, init, loc }); + } + const call: IrExpr = { + kind: "libCall", + fn: callee, + args: [...args, ...(fn.checked?.args ?? []).map((value) => boolLit(value, loc))], + type: result, + loc, + }; + body.push( + result.kind === "void" + ? { kind: "exprStmt", expr: call, loc } + : { kind: "return", value: call, loc }, + ); + if (result.kind === "void") body.push({ kind: "return", value: null, loc }); + lowerer.liftedFns.push({ name: fnName, params, returnType: result, locals, body, loc }); + } + return { kind: "closure", fnName, captures: [], type: funcType, loc }; +} + +/** A JavaScript call of a builtin with named value parameters whose + * arguments are not the native parameters' types or omit one + * (`path.matchesGlob(123, glob)`, `path.toNamespacedPath()`): the call goes + * through the checked + * adapter (checkedNativeCallableValue), which applies Node's validation and + * answers the value Node answers. Null for every other call, which keeps + * the direct lowering. */ +export function jsCheckedBuiltinCall( + lowerer: Lowerer, + call: ts.CallExpression, + fn: BuiltinModuleFn, + display: string, + loc: SrcLoc, +): IrExpr | null { + const params = fn.valueParams; + if (!params?.some((param) => param.name !== undefined)) return null; + if (params.some((param) => param.mode === "rest")) return null; + if (!isJsSourceFile(ts.getSourceFileOfNode(call))) return null; + const args = call.arguments.nodes; + if (args.length > params.length || args.some((arg) => ts.isSpreadElement(arg))) return null; + // An omitted required argument is undefined to Node's validation too. + const required = params.filter((param) => param.mode === "required").length; + const mismatch = + args.length < required || + args.some((arg, index) => { + const want = params[index]!.type; + if (want.kind === "dyn") return false; + const type = lowerer.mapTypeOf(lowerer.typeOf(arg)); + return type === null || !typeEquals(type, want); + }); + if (!mismatch) return null; + const adapter = checkedNativeCallableValue(lowerer, fn, display, loc); + if (adapter.kind !== "closure") return null; + return { + kind: "call", + callee: adapter.fnName, + args: params.map((_, index) => + index < args.length ? lowerer.lowerExprExpecting(args[index]!, DYN) : dynUndefinedExpr(loc), + ), + type: fn.checked?.result ?? fn.result, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index de5bab8b7..c58d20c2d 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -256,7 +256,11 @@ import { nsUndefRead, } from "./lower-namespaces.js"; import { expandoMemberRead } from "./lower-expando.js"; -import { fenceNodeModuleMutationCall, lowerRequireCacheKeys } from "./lower-node-module.js"; +import { + fenceNodeModuleMutationCall, + isCjsRequireGlobal, + lowerRequireCacheKeys, +} from "./lower-node-module.js"; import { defaultAfterUndefined, lowerOptionalArgument, @@ -468,6 +472,19 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const inlineValue = lowerInlineRequireValue(lowerer, expr); if (inlineValue) return inlineValue; } + // `require("path/posix")` of a builtin as a value: the module's + // namespace, as a `const posix = require("path/posix")` binding is. + if ( + ts.isIdentifier(expr.expression) && + expr.expression.text === "require" && + isCjsRequireGlobal(lowerer, expr.expression) + ) { + const module = lowerer.builtinNamespaceModuleOf(expr); + if (module !== null && module !== "constants") { + const moduleId = `builtin:${module}`; + return { kind: "moduleNsRef", moduleId, type: { kind: "moduleNs", moduleId }, loc }; + } + } // A call whose chain ROOTS at an ambient-undefined name (`declare // const value: Y | undefined; value?.foo("a")`, `declare function diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..c56f6187e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -180,6 +180,7 @@ import { import { assertedConversion } from "./coercions/asserted.js"; import { lowerUtilTypeValue, + checkedNativeCallableValue, pathModuleValue, utilTypesModuleValue, } from "./lower-builtin-values.js"; @@ -3419,6 +3420,8 @@ export class Lowerer { /** Builtin function values whose `length` differs from their adapter's * parameter count (BuiltinModuleFn.valueLength), by adapter name. */ readonly builtinCallableValueLengths = new Map(); + /** Typed builtin adapters with a checked twin (checkedBuiltinValue). */ + readonly builtinCheckedValues = new Map IrExpr>(); /** Optional-chain lowering state. While a chain body lowers, the guarded * receiver NODE reads as a chainRecv (typed by the narrowed arm) instead * of re-lowering, its checker type reads non-nullish (typeOf), and the @@ -11845,9 +11848,28 @@ export class Lowerer { if (dyn) return dyn; this.badType(node, t); } + // A JavaScript binding whose inferred type holds a union of functions + // and arrays (a test table of `[fn, cases]` rows, or one row + // destructured): the checked-dynamic kind dynFallbackType gives the + // unmappable spellings of such tables, so the rows keep their functions' + // names and the destructured items their values. + if (isJsSourceFile(ts.getSourceFileOfNode(node)) && this.mixesFunctionsWithArrays(mapped)) + return DYN; return jsOpenObjectType(node, mapped, this.shapes, this.unions); } + /** Whether `type`, or an array element type inside it, is a union with + * both a function arm and an array arm. */ + private mixesFunctionsWithArrays(type: IrType): boolean { + if (type.kind === "array") return this.mixesFunctionsWithArrays(type.elem); + if (type.kind !== "union") return false; + const arms = this.unions.get(type.unionId)?.arms ?? []; + return ( + (arms.some((arm) => arm.kind === "func") && arms.some((arm) => arm.kind === "array")) || + arms.some((arm) => arm.kind === "array" && this.mixesFunctionsWithArrays(arm)) + ); + } + /** Exact-shape enforcement (SC2002). Records are monomorphic structs, so * everywhere a value flows into a typed slot (call arg, initializer, * assignment, field, return) the shapes must MATCH — or width-coerce: @@ -12260,6 +12282,30 @@ export class Lowerer { ); if (boxed) return boxed; } + // A node:path family is its module object (pathModuleValue), the + // bare module the target platform's family. + if ( + expr.type.kind === "moduleNs" && + (expr.type.moduleId === "builtin:path" || + expr.type.moduleId === "builtin:path/posix" || + expr.type.moduleId === "builtin:path/win32") + ) { + const family = + expr.type.moduleId !== "builtin:path" + ? expr.type.moduleId.slice("builtin:".length) + : builtinModuleConstOf(this, "path", "sep") === "\\" + ? "path/win32" + : "path/posix"; + const value = pathModuleValue(this, family, expr.loc); + if (expr.kind === "moduleNsRef") return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr, loc: expr.loc }], + result: value, + type: DYN, + loc: expr.loc, + }; + } if ( expr.type.kind === "moduleNs" && expr.type.moduleId === "builtin:events" && @@ -12311,7 +12357,7 @@ export class Lowerer { expr.kind === "closure" ? this.builtinCallableValueLengths.get(expr.fnName) : undefined; return { kind: "dynFrom", - value: expr, + value: this.checkedBuiltinValue(expr), type: DYN, loc: expr.loc, ...(liveRef ? { liveRef: true as const } : {}), @@ -16736,7 +16782,10 @@ export class Lowerer { (bi.module === "path" || bi.module === "path/posix" || bi.module === "path/win32") && (bi.member === "posix" || bi.member === "win32") ) { - return pathModuleValue(this, `path/${bi.member}`, loc); + // The family's namespace; an untyped use converts it to the module + // object (pathModuleValue, through coerceToExpected). + const moduleId = `builtin:path/${bi.member}`; + return { kind: "moduleNsRef", moduleId, type: { kind: "moduleNs", moduleId }, loc }; } // events.defaultMaxListeners READS the process-wide default (its // write twin routes through emitter.setDefaultMaxChk). Through an ES @@ -16879,6 +16928,7 @@ export class Lowerer { lowerNativeCallableValue(fn: BuiltinModuleFn, display: string, loc: SrcLoc): IrExpr | null { const valueParams = fn.valueParams; if (!valueParams) return null; + if (fn.checked?.only) return checkedNativeCallableValue(this, fn, display, loc); const shapes: ParamShape[] = valueParams.map((param): ParamShape => { if (param.mode === "rest") return { mode: "rest", type: arrayOf(param.type) }; if (param.mode === "optional") { @@ -16988,9 +17038,24 @@ export class Lowerer { loc, }); } + if (valueParams.some((param) => param.name !== undefined)) { + this.builtinCheckedValues.set(fnName, () => + checkedNativeCallableValue(this, fn, display, loc), + ); + } return { kind: "closure", fnName, captures: [], type: funcType, loc }; } + /** A builtin function value about to become untyped: its checked twin + * when the builtin names its parameters (checkedNativeCallableValue), so + * untyped callers meet Node's argument validation. Typed slots keep the + * typed adapter. */ + checkedBuiltinValue(expr: IrExpr): IrExpr { + const checked = + expr.kind === "closure" ? this.builtinCheckedValues.get(expr.fnName) : undefined; + return checked ? checked() : expr; + } + isBuiltinCallableValue(expr: IrExpr): boolean { return ( expr.kind === "closure" && diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index 405d2af5f..b7542aae3 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -22,6 +22,20 @@ export function lowerModuleNamespaceElement( ): IrExpr | null { const type = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); if (type?.kind !== "moduleNs") return null; + // A builtin namespace with a module object (`path[platform]`): the keyed + // read of that object. + if (type.moduleId.startsWith("builtin:")) { + const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN); + if (receiver.type.kind !== "dyn") return null; + const key = lowerer.lowerExpr(expr.argumentExpression); + return { + kind: "dynKeyGet", + key: key.type.kind === "string" ? key : lowerer.coerceInto(expr.argumentExpression, key, DYN), + value: receiver, + type: DYN, + loc: locOf(expr), + }; + } const source = lowerer.sourceFileOfModuleNamespace(type); if (!source) return null; const loc = locOf(expr); diff --git a/packages/compiler/src/frontend/lowering/strict-equality.ts b/packages/compiler/src/frontend/lowering/strict-equality.ts index 8a87c8111..06b731dfa 100644 --- a/packages/compiler/src/frontend/lowering/strict-equality.ts +++ b/packages/compiler/src/frontend/lowering/strict-equality.ts @@ -12,6 +12,7 @@ import { } from "../../ir/ir.js"; import type { Lowerer } from "./lowerer.js"; import { unionComparison } from "./union-comparison.js"; +import { builtinModuleConstOf } from "./surfaces.js"; const IDENTITY_KINDS: ReadonlySet = new Set([ "array", @@ -29,6 +30,19 @@ const IDENTITY_KINDS: ReadonlySet = new Set([ "moduleNs", ]); +/** Whether two namespace references are the same module object: the bare + * node:path namespace IS its target platform's family (`path === + * path.posix` on POSIX targets); other namespaces are their module. */ +export function sameNamespace(lowerer: Lowerer, a: string, b: string): boolean { + const family = (id: string): string => + id !== "builtin:path" + ? id + : builtinModuleConstOf(lowerer, "path", "sep") === "\\" + ? "builtin:path/win32" + : "builtin:path/posix"; + return family(a) === family(b); +} + function atomicEquality( lowerer: Lowerer, left: IrExpr, @@ -41,6 +55,8 @@ function atomicEquality( if ([a.kind, b.kind].some((kind) => kind === "dyn" || kind === "caught" || kind === "void")) return null; if (isUnitType(a) || isUnitType(b)) return boolLit(a.kind === b.kind, loc); + if (left.kind === "moduleNsRef" && right.kind === "moduleNsRef") + return boolLit(sameNamespace(lowerer, left.moduleId, right.moduleId), loc); if (a.kind === "object" && b.kind === "object" && !typeEquals(a, b)) { if (lowerer.isSubclassOf(a.className, b.className)) left = lowerer.upcastTo(left, b.className); else if (lowerer.isSubclassOf(b.className, a.className)) diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..eef92c26b 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -823,12 +823,25 @@ export interface BuiltinModuleFn { /** The function value's `length` when it is not the count of its * required value parameters (`Math.max`'s 2 over its rest pack). */ valueLength?: number; + /** The order Node validates named value parameters in, when it is not + * the parameter order (basename checks its suffix first). */ + valueOrder?: readonly number[]; + /** The runtime entry point an untyped caller's adapter calls with every + * argument as given, when it validates them itself (resolve's last-first + * walk) or the value has no typed form (`only`: parse's record result, + * format's untyped pathObject). `args` are constants passed after the + * arguments (the win32 flag of an entry both path families share). */ + checked?: { fn: IrLibFn; args: readonly boolean[]; result?: IrType; only?: true }; } +/** `name` is the argument name Node's validator reports + * (ERR_INVALID_ARG_TYPE's 'The "path" argument ...'): the adapter accepts + * any value there and validates it, so a string parameter arrives checked + * and a dyn one reaches its valueFn as given. */ export type BuiltinValueParam = - | { mode: "required"; type: IrType } - | { mode: "optional"; type: IrType; defaultValue: string | number | undefined } - | { mode: "rest"; type: IrType }; + | { mode: "required"; type: IrType; name?: string } + | { mode: "optional"; type: IrType; defaultValue: string | number | undefined; name?: string } + | { mode: "rest"; type: IrType; name?: string }; /** The common first-class shape for builtin functions whose supported * source signature is already exactly the fixed native ABI. Keeping the @@ -930,26 +943,27 @@ const PATH_MODULE_FNS: Record = { params: [STRING], result: STRING, variadicPack: true, - valueParams: [{ mode: "rest", type: STRING }], + valueParams: [{ mode: "rest", type: STRING, name: "path" }], }, resolve: { fn: "path.resolve", params: [STRING], result: STRING, variadicPack: true, - valueParams: [{ mode: "rest", type: STRING }], + valueParams: [{ mode: "rest", type: STRING, name: "paths" }], + checked: { fn: "path.resolveValue", args: [false] }, }, normalize: { fn: "path.normalize", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, dirname: { fn: "path.dirname", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, basename: { fn: "path.basename", @@ -957,48 +971,65 @@ const PATH_MODULE_FNS: Record = { result: STRING, defaults: [""], valueParams: [ - { mode: "required", type: STRING }, - { mode: "optional", type: STRING, defaultValue: "" }, + { mode: "required", type: STRING, name: "path" }, + { mode: "optional", type: STRING, defaultValue: "", name: "suffix" }, ], + valueOrder: [1, 0], }, extname: { fn: "path.extname", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, isAbsolute: { fn: "path.isAbsolute", params: [STRING], result: BOOL, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, relative: { fn: "path.relative", params: [STRING, STRING], result: STRING, valueParams: [ - { mode: "required", type: STRING }, - { mode: "required", type: STRING }, + { mode: "required", type: STRING, name: "from" }, + { mode: "required", type: STRING, name: "to" }, ], }, toNamespacedPath: { fn: "path.toNamespacedPath", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], + checked: { fn: "path.toNamespacedValue", args: [false], result: DYN }, }, matchesGlob: { fn: "path.matchesGlob", params: [STRING, STRING], result: BOOL, - valueParams: exactValueParams(STRING, STRING), + valueParams: [ + { mode: "required", type: STRING, name: "path" }, + { mode: "required", type: STRING, name: "pattern" }, + ], }, // Special-cased (lowerPathParseCall / lowerPathFormatCall): the // ParsedPath record and the pathObject argument follow the call site's // mapped shapes. - parse: { fn: "path.parse", params: [STRING], result: VOID }, - format: { fn: "path.format", params: [], result: STRING }, + parse: { + fn: "path.parse", + params: [STRING], + result: VOID, + valueParams: [{ mode: "required", type: STRING, name: "path" }], + checked: { fn: "path.parseValue", args: [false], result: DYN, only: true }, + }, + format: { + fn: "path.format", + params: [], + result: STRING, + valueParams: [{ mode: "required", type: DYN, name: "pathObject" }], + checked: { fn: "path.formatValue", args: [false], result: DYN, only: true }, + }, }; /** The win32 twins (scr_path.c's exact port of Node-v24 path.win32): @@ -1010,26 +1041,27 @@ const PATH_WIN32_MODULE_FNS: Record = { params: [STRING], result: STRING, variadicPack: true, - valueParams: [{ mode: "rest", type: STRING }], + valueParams: [{ mode: "rest", type: STRING, name: "path" }], }, resolve: { fn: "path.win32Resolve", params: [STRING], result: STRING, variadicPack: true, - valueParams: [{ mode: "rest", type: STRING }], + valueParams: [{ mode: "rest", type: STRING, name: "paths" }], + checked: { fn: "path.resolveValue", args: [true] }, }, normalize: { fn: "path.win32Normalize", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, dirname: { fn: "path.win32Dirname", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, basename: { fn: "path.win32Basename", @@ -1037,48 +1069,65 @@ const PATH_WIN32_MODULE_FNS: Record = { result: STRING, defaults: [""], valueParams: [ - { mode: "required", type: STRING }, - { mode: "optional", type: STRING, defaultValue: "" }, + { mode: "required", type: STRING, name: "path" }, + { mode: "optional", type: STRING, defaultValue: "", name: "suffix" }, ], + valueOrder: [1, 0], }, extname: { fn: "path.win32Extname", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, isAbsolute: { fn: "path.win32IsAbsolute", params: [STRING], result: BOOL, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], }, relative: { fn: "path.win32Relative", params: [STRING, STRING], result: STRING, valueParams: [ - { mode: "required", type: STRING }, - { mode: "required", type: STRING }, + { mode: "required", type: STRING, name: "from" }, + { mode: "required", type: STRING, name: "to" }, ], }, toNamespacedPath: { fn: "path.win32ToNamespacedPath", params: [STRING], result: STRING, - valueParams: [{ mode: "required", type: STRING }], + valueParams: [{ mode: "required", type: STRING, name: "path" }], + checked: { fn: "path.toNamespacedValue", args: [true], result: DYN }, }, matchesGlob: { fn: "path.win32MatchesGlob", params: [STRING, STRING], result: BOOL, - valueParams: exactValueParams(STRING, STRING), + valueParams: [ + { mode: "required", type: STRING, name: "path" }, + { mode: "required", type: STRING, name: "pattern" }, + ], }, // Special-cased (lowerPathParseCall / lowerPathFormatCall): the // ParsedPath record and the pathObject argument follow the call site's // mapped shapes. - parse: { fn: "path.win32Parse", params: [STRING], result: VOID }, - format: { fn: "path.win32Format", params: [], result: STRING }, + parse: { + fn: "path.win32Parse", + params: [STRING], + result: VOID, + valueParams: [{ mode: "required", type: STRING, name: "path" }], + checked: { fn: "path.parseValue", args: [true], result: DYN, only: true }, + }, + format: { + fn: "path.win32Format", + params: [], + result: STRING, + valueParams: [{ mode: "required", type: DYN, name: "pathObject" }], + checked: { fn: "path.formatValue", args: [true], result: DYN, only: true }, + }, }; /** util.types's own keys in Node's order (Node 24.22.0). The table is the diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 55a7631a4..8a3a03020 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5419,6 +5419,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return DYN; } + // A module namespace beside another value in one slot (`[path.posix, + // path.win32]`, a `['posix', path.posix]` table row): a namespace has + // no union representation, and as a value it is the runtime's module + // object, so the slot is checked-dynamic. A namespace or null keeps + // its union (a probed process.getBuiltinModule result). + if ( + arms.some((a) => a.kind === "moduleNs") && + arms.filter((a) => !isUnitType(a)).length > 1 && + !unions.recursivePending(widened) + ) { + return DYN; + } if ( arms.some( (a) => diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..6272b9e44 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -131,6 +131,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "buffer.byteLenValue", "buffer.allocValue", "fs.realpathValue", + "dyn.validateString", + "dyn.validateStrings", + "path.resolveValue", + "path.parseValue", + "path.formatValue", "bytes.construct", "arrayBuffer.new", "sharedArrayBuffer.new", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..ea87f5bc8 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -802,6 +802,14 @@ function buildLibFnSignatures() { * value: path and options arrive unchecked; the result is a string or a * Buffer by the options' encoding. */ "fs.realpathValue": { argTypes: [DYN, DYN, BOOL], result: DYN }, + // Builtin functions read as values: validateString under Node's argument + // name, and node:path's value adapters (scr_builtin_values.c). + "dyn.validateString": { argTypes: [DYN, STRING], result: STRING }, + "dyn.validateStrings": { argTypes: [DYN, STRING], result: arrayOf(STRING) }, + "path.resolveValue": { argTypes: [DYN, BOOL], result: STRING }, + "path.parseValue": { argTypes: [DYN, BOOL], result: DYN }, + "path.formatValue": { argTypes: [DYN, BOOL], result: DYN }, + "path.toNamespacedValue": { argTypes: [DYN, BOOL], result: DYN }, /** Native realpath with syscall "realpath" on failure. +1 fresh string. */ "fs.realpathNativeSync": { argTypes: [STRING], result: STRING }, /** The os.userInfo() field trio (uv_os_get_passwd's slices): pw_name, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..ec61ee77c 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1020,6 +1020,12 @@ const RUNTIME_FEATURE_RULES = { "buffer.installValue", "buffer.staticValue", "buffer.prototypeMember", + "dyn.validateString", + "dyn.validateStrings", + "path.resolveValue", + "path.parseValue", + "path.formatValue", + "path.toNamespacedValue", ], }), /** The stream unit emits through the emitter unit, also when the @@ -1160,6 +1166,9 @@ const RUNTIME_FEATURE_RULES = { ...UTIL_CALLABLE_LIB_FNS, "rl.terminal", "str.localeCase", + // win32.relative compares with String.prototype.toLowerCase's + // Unicode case mapping. + "path.win32Relative", ], prefixes: URL_PREFIXES, expr: (node, _tables) => diff --git a/packages/compiler/test/process-builtin-values.test.ts b/packages/compiler/test/process-builtin-values.test.ts index 1ced69b04..4b760f41b 100644 --- a/packages/compiler/test/process-builtin-values.test.ts +++ b/packages/compiler/test/process-builtin-values.test.ts @@ -16,7 +16,7 @@ test.each(["llvm"] as const)( const load = process.getBuiltinModule; try { load("node:fs"); } catch (error) { console.log(error.code, error.message.includes("native builtin 'fs'")); } const path = load("path"); -try { path.parse("/a"); } catch (error) { console.log(error.code, error.message.includes(".parse'")); } +console.log(JSON.stringify(path.parse("/a"))); const worker = load("worker_threads"); try { console.log(worker.Worker); } catch (error) { console.log(error.code, error.message.includes("worker_threads.Worker")); } console.log(path.posix.join("still", "works"), worker.isMainThread); @@ -39,7 +39,7 @@ try { console.log(segmenter.segment); } catch (error) { console.log(error.messag expect(child.status, child.stderr).toBe(0); expect(child.stderr).toBe(""); expect(child.stdout).toBe( - "SC2020 true\nSC2020 true\nSC2020 true\nstill/works true\nSC2020 true\ntrue\ntrue\n", + 'SC2020 true\n{"root":"/","dir":"/","base":"a","ext":"","name":"a"}\nSC2020 true\nstill/works true\nSC2020 true\ntrue\ntrue\n', ); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-array-tables.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-array-tables.cjs.json new file mode 100644 index 000000000..e7df3aee6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-array-tables.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-function-array-tables.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-function-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-function-values.cjs.json new file mode 100644 index 000000000..30776ba0d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-function-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/path-function-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-untyped-calls.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-untyped-calls.cjs.json new file mode 100644 index 000000000..c67256c42 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-untyped-calls.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/path-untyped-calls.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-win32-relative-case.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-win32-relative-case.cjs.json new file mode 100644 index 000000000..451244794 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-win32-relative-case.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/path-win32-relative-case.cjs" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/library-matrix.mjs b/packages/runtime-pack-common/library-matrix.mjs index 1a621a375..a6568894e 100644 --- a/packages/runtime-pack-common/library-matrix.mjs +++ b/packages/runtime-pack-common/library-matrix.mjs @@ -3,6 +3,7 @@ export function withLibraryRuntimeFlavors(matrix) { const omitted = new Set(["scr_async.c", "scr_crypto_async.c", "scr_child.c", "scr_ffi.c"]); const optional = new Set([ "scr_regex.c", + "scr_path_win32_relative.c", "scr_glob.c", "scr_assert.c", "scr_inspect.c", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..e8520c427 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -63,6 +63,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_copying.c", "copying"], ["scr_file_handle.c", "fileHandle"], ["scr_regex.c", "regex"], + // path.win32.relative uses the regex unit's Unicode case mapping. + ["scr_path_win32_relative.c", "regex"], ["scr_glob.c", "regex"], ["scr_idna.c", "regex"], ["scr_assert.c", any("assert", "regex", "symbol")], diff --git a/packages/runtime/src/scr_builtin_values.c b/packages/runtime/src/scr_builtin_values.c index 084a1d50d..aa8124903 100644 --- a/packages/runtime/src/scr_builtin_values.c +++ b/packages/runtime/src/scr_builtin_values.c @@ -472,3 +472,286 @@ ScrDyn *scr_buffer_static_value(const ScrStr *name) { scr_dyn_release(buffer); return member; } + +/* ── node:path functions read as values ────────────────────────────── + * A stored `path.posix.join` or a `path.win32` namespace passed around + * receives unchecked arguments, so these entries apply lib/path.js's + * validators (validateString, validateObject) under Node's argument names + * before the string algorithms in scr_path.c run. All borrow; results are + * +1, or NULL with Node's ERR_INVALID_ARG_TYPE pending. */ + +/* validateString(value, name) */ +static bool scr_bv_path_string(const ScrDyn *value, const char *name, size_t name_len) { + if (value->kind == SCR_DYN_STR) return true; + char text[64]; + int n = snprintf(text, sizeof text, "%.*s", (int)name_len, name); + if (n < 0) text[0] = 0; + scr_dyn_arg_type_fail(text, "of type string", value); + return false; +} + +ScrStr *scr_value_string(const ScrDyn *value, ScrStr *name) { + if (!scr_bv_path_string(value, name->data, name->len)) return NULL; + return scr_str_retain(value->v.str); +} + +/* validateString over every argument of a rest list, in order (join's). */ +ScrArr *scr_value_strings(const ScrDyn *args, ScrStr *name) { + size_t n = (size_t)scr_dyn_arr_len(args); + ScrArr *out = scr_arr_new(SCR_ELEM_STR, n); + for (size_t i = 0; i < n; i++) { + ScrDyn *arg = scr_dyn_arr_at(args, (double)i); + bool ok = scr_bv_path_string(arg, name->data, name->len); + if (ok) scr_arr_push_ref(out, scr_str_retain(arg->v.str)); + scr_dyn_release(arg); + if (!ok) { + scr_arr_release(out); + return NULL; + } + } + return out; +} + +static bool scr_bv_w32_sep(char c) { return c == '/' || c == '\\'; } + +static bool scr_bv_w32_ieq(const char *a, const char *b, size_t n) { + for (size_t i = 0; i < n; i++) { + char x = a[i] >= 'A' && a[i] <= 'Z' ? (char)(a[i] + 32) : a[i]; + char y = b[i] >= 'A' && b[i] <= 'Z' ? (char)(b[i] + 32) : b[i]; + if (x != y) return false; + } + return true; +} + +/* win32.resolve's root match for one argument: the device it names (a UNC + * share, a device namespace or a drive letter) written into `device` + * (capacity len + 4), its length returned, and whether it is absolute. */ +static size_t scr_bv_w32_device(const char *path, size_t len, char *device, bool *absolute) { + *absolute = false; + char code = path[0]; + if (len == 1) { + *absolute = scr_bv_w32_sep(code); + return 0; + } + if (scr_bv_w32_sep(code)) { + *absolute = true; + if (!scr_bv_w32_sep(path[1])) return 0; + size_t j = 2, last = 2; + while (j < len && !scr_bv_w32_sep(path[j])) j++; + if (!(j < len && j != last)) return 0; + size_t first = last, first_len = j - last; + last = j; + while (j < len && scr_bv_w32_sep(path[j])) j++; + if (!(j < len && j != last)) return 0; + last = j; + while (j < len && !scr_bv_w32_sep(path[j])) j++; + if (!(j == len || j != last)) return 0; + size_t n = 0; + device[n++] = '\\'; + device[n++] = '\\'; + memcpy(device + n, path + first, first_len); + n += first_len; + if (!(first_len == 1 && (path[first] == '.' || path[first] == '?'))) { + device[n++] = '\\'; + memcpy(device + n, path + last, j - last); + n += j - last; + } + return n; + } + if (((code >= 'A' && code <= 'Z') || (code >= 'a' && code <= 'z')) && path[1] == ':') { + device[0] = path[0]; + device[1] = ':'; + *absolute = len > 2 && scr_bv_w32_sep(path[2]); + return 2; + } + return 0; +} + +/* Whether resolve's last-first walk over args[from, n) — all strings — + * stops before it reaches args[from - 1]: posix stops at the first absolute + * argument, win32 once it has an absolute path on a resolved device. */ +static bool scr_bv_resolve_stops(const ScrDyn *args, long from, long n, bool win32) { + bool stops = false, absolute = false; + char *dev = NULL; + size_t dev_len = 0; + for (long i = n - 1; i >= from && !stops; i--) { + ScrDyn *arg = scr_dyn_arr_at(args, (double)i); + const ScrStr *s = arg->v.str; + if (s->len > 0 && !win32) { + stops = s->data[0] == '/'; + } else if (s->len > 0) { + char *device = malloc(s->len + 4); + if (!device) scr_trap("scriptc: out of memory\n"); + bool is_absolute; + size_t len = scr_bv_w32_device(s->data, s->len, device, &is_absolute); + bool other = false; + if (len > 0 && dev_len > 0) { + other = !(len == dev_len && scr_bv_w32_ieq(device, dev, len)); + } else if (len > 0) { + free(dev); + dev = device; + dev_len = len; + device = NULL; + } + free(device); + if (!other && absolute) { + stops = dev_len > 0; + } else if (!other) { + absolute = is_absolute; + stops = is_absolute && dev_len > 0; + } + } + scr_dyn_release(arg); + } + free(dev); + return stops; +} + +/* resolve(...args): Node validates `paths[i]` as its walk reaches each + * argument, last first, and the walk ends at an absolute path, so a + * non-string before that point is never read. */ +ScrStr *scr_path_resolve_value(const ScrDyn *args, bool win32) { + long n = (long)scr_dyn_arr_len(args); + long last_bad = n - 1; + for (; last_bad >= 0; last_bad--) { + ScrDyn *arg = scr_dyn_arr_at(args, (double)last_bad); + bool string = arg->kind == SCR_DYN_STR; + scr_dyn_release(arg); + if (!string) break; + } + if (last_bad >= 0 && !scr_bv_resolve_stops(args, last_bad + 1, n, win32)) { + char name[32]; + int len = snprintf(name, sizeof name, "paths[%ld]", last_bad); + ScrDyn *arg = scr_dyn_arr_at(args, (double)last_bad); + scr_bv_path_string(arg, name, len < 0 ? 0 : (size_t)len); + scr_dyn_release(arg); + return NULL; + } + ScrArr *parts = scr_arr_new(SCR_ELEM_STR, (size_t)(n - last_bad - 1)); + for (long i = last_bad + 1; i < n; i++) { + ScrDyn *arg = scr_dyn_arr_at(args, (double)i); + scr_arr_push_ref(parts, scr_str_retain(arg->v.str)); + scr_dyn_release(arg); + } + ScrStr *out = win32 ? scr_path_win32_resolve(parts) : scr_path_resolve(parts); + scr_arr_release(parts); + return out; +} + +/* parse(path): validateString, then a fresh { root, dir, base, ext, name }. */ +ScrDyn *scr_path_parse_value(const ScrDyn *path, bool win32) { + if (!scr_bv_path_string(path, "path", 4)) return NULL; + ScrArr *parts = win32 ? scr_path_win32_parse(path->v.str) : scr_path_parse(path->v.str); + static const char *const keys[] = {"root", "dir", "base", "ext", "name"}; + ScrDyn *out = scr_dyn_new_obj(); + for (size_t i = 0; i < 5; i++) { + ScrStr *part = scr_arr_get_ref(parts, (double)i); + scr_dyn_obj_set(out, keys[i], strlen(keys[i]), scr_dyn_new_str(part)); + scr_str_release(part); + } + scr_arr_release(parts); + return out; +} + +/* ToString for a template position (`${value}`); NULL with the error + * pending (a Symbol). */ +static bool scr_bv_append(ScrJsonBuf *b, const ScrDyn *value) { + ScrStr *text = scr_dyn_string_coerce_js(value); + if (!text) return false; + scr_jb_write(b, text->data, text->len); + scr_str_release(text); + return true; +} + +/* format(pathObject): lib/path.js's _format and formatExt over any object, + * reading each property as Node does. The result is `base` itself when + * there is no dir, so it need not be a string. */ +ScrDyn *scr_path_format_value(const ScrDyn *object, bool win32) { + ScrStr *type = scr_dyn_typeof(object); + bool is_object = type->len == 6 && memcmp(type->data, "object", 6) == 0; + scr_str_release(type); + if (!is_object || object->kind == SCR_DYN_NULL || object->kind == SCR_DYN_ARR) { + scr_dyn_arg_type_fail("pathObject", "of type object", object); + return NULL; + } + ScrDyn *dir = scr_dyn_obj_read(object, "dir", 3); + if (!dir) return NULL; + if (!scr_dyn_truthy(dir)) { + scr_dyn_release(dir); + dir = scr_dyn_obj_read(object, "root", 4); + if (!dir) return NULL; + } + ScrDyn *base = scr_dyn_obj_read(object, "base", 4); + if (!base) goto fail_dir; + if (!scr_dyn_truthy(base)) { + scr_dyn_release(base); + base = NULL; + ScrJsonBuf b; + scr_jb_init(&b); + ScrDyn *name = scr_dyn_obj_read(object, "name", 4); + bool ok = name != NULL && (!scr_dyn_truthy(name) || scr_bv_append(&b, name)); + if (name) scr_dyn_release(name); + ScrDyn *ext = ok ? scr_dyn_obj_read(object, "ext", 3) : NULL; + ok = ext != NULL; + if (ok && scr_dyn_truthy(ext)) { + /* `${ext[0] === '.' ? '' : '.'}${ext}` */ + bool dot = false; + if (ext->kind == SCR_DYN_STR) { + dot = ext->v.str->len > 0 && ext->v.str->data[0] == '.'; + } else if (ext->kind == SCR_DYN_OBJ || ext->kind == SCR_DYN_ARR || ext->kind == SCR_DYN_FUNC) { + ScrDyn *first = ext->kind == SCR_DYN_ARR ? scr_dyn_arr_at(ext, 0) : scr_dyn_obj_read(ext, "0", 1); + ok = first != NULL; + dot = ok && first->kind == SCR_DYN_STR && first->v.str->len == 1 && first->v.str->data[0] == '.'; + if (first) scr_dyn_release(first); + } + if (ok && !dot) scr_jb_putc(&b, '.'); + ok = ok && scr_bv_append(&b, ext); + } + if (ext) scr_dyn_release(ext); + ScrStr *text = scr_jb_finish(&b); + if (!ok) { + scr_str_release(text); + goto fail_dir; + } + base = scr_dyn_new_str(text); + scr_str_release(text); + } + if (!scr_dyn_truthy(dir)) { + scr_dyn_release(dir); + return base; + } + ScrDyn *root = scr_dyn_obj_read(object, "root", 4); + if (!root) goto fail_base; + bool same = scr_dyn_strict_eq(dir, root); + scr_dyn_release(root); + ScrJsonBuf b; + scr_jb_init(&b); + bool ok = scr_bv_append(&b, dir); + if (ok && !same) scr_jb_putc(&b, win32 ? '\\' : '/'); + ok = ok && scr_bv_append(&b, base); + ScrStr *text = scr_jb_finish(&b); + scr_dyn_release(dir); + scr_dyn_release(base); + if (!ok) { + scr_str_release(text); + return NULL; + } + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; +fail_base: + scr_dyn_release(base); +fail_dir: + scr_dyn_release(dir); + return NULL; +} + +/* toNamespacedPath(path): posix answers its argument; win32 answers a + * non-string or empty argument as it is and namespaces a string. */ +ScrDyn *scr_path_to_namespaced_value(ScrDyn *path, bool win32) { + if (!win32 || path->kind != SCR_DYN_STR || path->v.str->len == 0) return scr_dyn_retain(path); + ScrStr *text = scr_path_win32_to_namespaced_path(path->v.str); + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 343930580..e3e118622 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -2275,6 +2275,12 @@ const char *scr_dyn_specific_type(const ScrDyn *v, char *buf, size_t cap); * error.argTypeThrow libCall). Borrows all three; always throws. */ SCR_COLD void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got); SCR_COLD void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got); +/* validateString(value, name) for a builtin function read as a value + * (scr_builtin_values.c): the string (+1), or NULL with Node's + * ERR_INVALID_ARG_TYPE pending; strings checks each element of a rest list + * in order and answers them as a string[]. Borrows both. */ +ScrStr *scr_value_string(const ScrDyn *value, ScrStr *name); +ScrArr *scr_value_strings(const ScrDyn *args, ScrStr *name); /* The property flavor ("The \"options.x\" property must be ...") — the * option-bag validators' gate (error.propTypeThrow). Always throws. */ SCR_COLD void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got); diff --git a/packages/runtime/src/scr_path.c b/packages/runtime/src/scr_path.c index c845d470d..310ad09c2 100644 --- a/packages/runtime/src/scr_path.c +++ b/packages/runtime/src/scr_path.c @@ -491,9 +491,10 @@ ScrStr *scr_path_relative(ScrStr *from, ScrStr *to) { * * Two knowingly-ASCII spots (documented divergences): the * case-insensitive comparisons Node does with String.prototype. - * toLowerCase/toUpperCase (win32.relative, resolve's device compare, the - * reserved-name check) fold ASCII only here — non-ASCII device/server - * names whose Unicode case-folding differs compare case-SENSITIVELY. */ + * toLowerCase/toUpperCase in resolve's device compare and the reserved-name + * check fold ASCII only here — non-ASCII device/server names whose Unicode + * case-folding differs compare case-SENSITIVELY. win32.relative lowercases + * with String.prototype.toLowerCase and counts UTF-16 units, as Node does. */ static bool scr_path_w32_is_device_root(char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); @@ -975,114 +976,6 @@ bool scr_path_win32_is_absolute(ScrStr *path) { scr_path_w32_is_sep(p[2])); } -ScrStr *scr_path_win32_relative(ScrStr *from, ScrStr *to) { - if (from->len == to->len && memcmp(from->data, to->data, from->len) == 0) { - return scr_str_new("", 0); - } - /* fromOrig = win32.resolve(from), toOrig = win32.resolve(to). */ - ScrArr *pack = scr_arr_new(SCR_ELEM_STR, 1); - scr_arr_push_ref(pack, scr_str_retain(from)); - ScrStr *rfrom = scr_path_win32_resolve(pack); - scr_arr_release(pack); - pack = scr_arr_new(SCR_ELEM_STR, 1); - scr_arr_push_ref(pack, scr_str_retain(to)); - ScrStr *rto = scr_path_win32_resolve(pack); - scr_arr_release(pack); - - if (rfrom->len == rto->len && memcmp(rfrom->data, rto->data, rfrom->len) == 0) { - scr_str_release(rfrom); - scr_str_release(rto); - return scr_str_new("", 0); - } - /* Node lowercases both sides (case-insensitive comparison, ASCII fold - * here — see the header comment). Byte-lowercasing never changes - * lengths, so Node's changed-length Unicode branch cannot trigger. */ - char *flow = malloc(rfrom->len ? rfrom->len : 1); - char *tlow = malloc(rto->len ? rto->len : 1); - if (!flow || !tlow) { - scr_trap("scriptc: out of memory\n"); - } - for (size_t k = 0; k < rfrom->len; k++) flow[k] = scr_path_w32_lower(rfrom->data[k]); - for (size_t k = 0; k < rto->len; k++) tlow[k] = scr_path_w32_lower(rto->data[k]); - if (rfrom->len == rto->len && memcmp(flow, tlow, rfrom->len) == 0) { - free(flow); - free(tlow); - scr_str_release(rfrom); - scr_str_release(rto); - return scr_str_new("", 0); - } - - /* Trim leading backslashes, and trailing ones (UNC paths only). */ - long from_start = 0; - while (from_start < (long)rfrom->len && flow[from_start] == '\\') from_start++; - long from_end = (long)rfrom->len; - while (from_end - 1 > from_start && flow[from_end - 1] == '\\') from_end--; - const long from_len = from_end - from_start; - - long to_start = 0; - while (to_start < (long)rto->len && tlow[to_start] == '\\') to_start++; - long to_end = (long)rto->len; - while (to_end - 1 > to_start && tlow[to_end - 1] == '\\') to_end--; - const long to_len = to_end - to_start; - - const long length = from_len < to_len ? from_len : to_len; - long last_common_sep = -1; - long i = 0; - for (; i < length; i++) { - char from_code = flow[from_start + i]; - if (from_code != tlow[to_start + i]) break; - if (from_code == '\\') last_common_sep = i; - } - ScrStr *result = NULL; - if (i != length) { - /* Mismatch before any common separator: return toOrig. */ - if (last_common_sep == -1) result = scr_str_new(rto->data, rto->len); - } else { - if (to_len > length) { - if (tlow[to_start + i] == '\\') { - /* `from` is the exact base path of `to`. */ - result = scr_str_new(rto->data + to_start + i + 1, (size_t)(to_end - (to_start + i + 1))); - } else if (i == 2) { - /* `from` is the device root. */ - result = scr_str_new(rto->data + to_start + i, (size_t)(to_end - (to_start + i))); - } - } - if (result == NULL && from_len > length) { - if (flow[from_start + i] == '\\') last_common_sep = i; - else if (i == 2) last_common_sep = 3; - } - if (result == NULL && last_common_sep == -1) last_common_sep = 0; - } - if (result != NULL) { - free(flow); - free(tlow); - scr_str_release(rfrom); - scr_str_release(rto); - return result; - } - - PathBuf out; - pb_init(&out); - for (i = from_start + last_common_sep + 1; i <= from_end; ++i) { - if (i == from_end || flow[i] == '\\') { - if (out.len == 0) pb_append(&out, "..", 2); - else pb_append(&out, "\\..", 3); - } - } - to_start += last_common_sep; - if (out.len == 0) { - /* Node reads toOrig.charCodeAt(toStart) — NaN out of range. */ - if (to_start < (long)rto->len && rto->data[to_start] == '\\') ++to_start; - } - /* toOrig.slice(toStart, toEnd) — "" when the range is empty/inverted. */ - if (to_end > to_start) pb_append(&out, rto->data + to_start, (size_t)(to_end - to_start)); - free(flow); - free(tlow); - scr_str_release(rfrom); - scr_str_release(rto); - return pb_take(&out); -} - ScrStr *scr_path_win32_to_namespaced_path(ScrStr *path) { if (path->len == 0) return scr_str_retain(path); ScrArr *pack = scr_arr_new(SCR_ELEM_STR, 1); diff --git a/packages/runtime/src/scr_path.h b/packages/runtime/src/scr_path.h index de5e85491..3c9e5e5e1 100644 --- a/packages/runtime/src/scr_path.h +++ b/packages/runtime/src/scr_path.h @@ -53,6 +53,20 @@ ScrArr *scr_path_win32_parse(ScrStr *path); ScrStr *scr_path_format(ScrStr *root, ScrStr *dir, ScrStr *base, ScrStr *name, ScrStr *ext); ScrStr *scr_path_win32_format(ScrStr *root, ScrStr *dir, ScrStr *base, ScrStr *name, ScrStr *ext); +/* ── node:path functions read as values (scr_builtin_values.c) ─────── + * The adapters behind a stored `path.posix.join` or a `path.win32` + * namespace passed around, whose arguments arrive unchecked: lib/path.js's + * validateString/validateObject under Node's argument names, then the + * functions above. All borrow; results are +1, or NULL with Node's + * ERR_INVALID_ARG_TYPE pending. resolve_value validates `paths[i]` only as + * far as Node's last-first walk reads them; format_value answers `base` + * itself when there is no dir (Node's _format), so its result is a dyn + * value. */ +struct ScrDyn; +ScrStr *scr_path_resolve_value(const struct ScrDyn *args, bool win32); +struct ScrDyn *scr_path_parse_value(const struct ScrDyn *path, bool win32); +struct ScrDyn *scr_path_format_value(const struct ScrDyn *object, bool win32); +struct ScrDyn *scr_path_to_namespaced_value(struct ScrDyn *path, bool win32); /* path.matchesGlob (scr_glob.c): Node 24's minimatch with matchGlobPattern's * options, case-insensitive on macOS and Windows hosts like Node. Borrows * both; a brace sequence over 2^32 - 1 elements throws Node's RangeError, a diff --git a/packages/runtime/src/scr_path_win32_relative.c b/packages/runtime/src/scr_path_win32_relative.c new file mode 100644 index 000000000..b827b9c5f --- /dev/null +++ b/packages/runtime/src/scr_path_win32_relative.c @@ -0,0 +1,169 @@ +/* node:path's win32.relative (scr_path.c holds the rest of path.win32). + * Node lowercases both resolved paths with String.prototype.toLowerCase, + * so the comparison uses the runtime's Unicode case mapping + * (scr_str_to_lower, scr_regex.c). That keeps scr_path.c free of the case + * tables: this unit links with the regex unit, which the compiler selects + * for every program that calls path.win32.relative. + * + * Inputs are BORROWED; results are fresh (+1). Nothing here throws. */ +#include "scr_runtime.h" + +#include + +/* win32.relative when lowercasing changed a length: Node compares the + * '\\'-separated segments case-insensitively instead (a trailing empty + * segment dropped) and joins the rest of `to`. */ +static ScrStr *scr_path_w32_relative_split(ScrStr *from, ScrStr *to) { + ScrArr *parts[2]; + ScrStr *sides[2] = {from, to}; + for (int side = 0; side < 2; side++) { + ScrStr *s = sides[side]; + parts[side] = scr_arr_new(SCR_ELEM_STR, 4); + size_t start = 0; + for (size_t k = 0; k <= s->len; k++) { + if (k == s->len || s->data[k] == '\\') { + if (!(k == s->len && k == start)) { + scr_arr_push_ref(parts[side], scr_str_new(s->data + start, k - start)); + } + start = k + 1; + } + } + } + size_t from_len = scr_arr_len(parts[0]), to_len = scr_arr_len(parts[1]); + size_t length = from_len < to_len ? from_len : to_len; + size_t i = 0; + for (; i < length; i++) { + ScrStr *a = scr_arr_get_ref(parts[0], (double)i); + ScrStr *b = scr_arr_get_ref(parts[1], (double)i); + ScrStr *la = scr_str_to_lower(a); + ScrStr *lb = scr_str_to_lower(b); + bool same = la->len == lb->len && memcmp(la->data, lb->data, la->len) == 0; + scr_str_release(la); + scr_str_release(lb); + scr_str_release(a); + scr_str_release(b); + if (!same) break; + } + ScrJsonBuf out; + scr_jb_init(&out); + if (i == 0) { + scr_jb_write(&out, to->data, to->len); + } else { + size_t ups = i == length ? (to_len > length ? 0 : from_len > length ? from_len - i : 0) : from_len - i; + for (size_t k = 0; k < ups; k++) { + if (i == length && k + 1 == ups) scr_jb_write(&out, "..", 2); + else scr_jb_write(&out, "..\\", 3); + } + if (i < length || to_len > length) { + for (size_t k = i; k < to_len; k++) { + ScrStr *part = scr_arr_get_ref(parts[1], (double)k); + if (k > i) scr_jb_putc(&out, '\\'); + scr_jb_write(&out, part->data, part->len); + scr_str_release(part); + } + } + } + scr_arr_release(parts[0]); + scr_arr_release(parts[1]); + return scr_jb_finish(&out); +} + +/* win32.relative over the lowercased paths `from` and `to`, whose UTF-16 + * lengths are the originals': Node's longest-common-path walk by UTF-16 + * unit, slicing the original `orig_to`. */ +static ScrStr *scr_path_w32_relative_units(ScrStr *from, ScrStr *to, ScrStr *orig_to) { + const double bs = '\\'; + double from_n = scr_str_utf16_len(from), to_n = scr_str_utf16_len(to); + /* Trim leading backslashes, and trailing ones (UNC paths only). */ + double from_start = 0; + while (from_start < from_n && scr_str_char_code_at(from, from_start) == bs) from_start++; + double from_end = from_n; + while (from_end - 1 > from_start && scr_str_char_code_at(from, from_end - 1) == bs) from_end--; + const double from_len = from_end - from_start; + double to_start = 0; + while (to_start < to_n && scr_str_char_code_at(to, to_start) == bs) to_start++; + double to_end = to_n; + while (to_end - 1 > to_start && scr_str_char_code_at(to, to_end - 1) == bs) to_end--; + const double to_len = to_end - to_start; + + const double length = from_len < to_len ? from_len : to_len; + double last_common_sep = -1; + double i = 0; + for (; i < length; i++) { + double from_code = scr_str_char_code_at(from, from_start + i); + if (from_code != scr_str_char_code_at(to, to_start + i)) break; + if (from_code == bs) last_common_sep = i; + } + if (i != length) { + /* Mismatch before any common separator: return toOrig. */ + if (last_common_sep == -1) return scr_str_retain(orig_to); + } else { + if (to_len > length) { + /* `from` is the exact base path of `to`, or the device root. */ + if (scr_str_char_code_at(to, to_start + i) == bs) + return scr_str_slice(orig_to, to_start + i + 1, to_n); + if (i == 2) return scr_str_slice(orig_to, to_start + i, to_n); + } + if (from_len > length) { + if (scr_str_char_code_at(from, from_start + i) == bs) last_common_sep = i; + else if (i == 2) last_common_sep = 3; + } + if (last_common_sep == -1) last_common_sep = 0; + } + ScrJsonBuf out; + scr_jb_init(&out); + for (i = from_start + last_common_sep + 1; i <= from_end; ++i) { + if (i == from_end || scr_str_char_code_at(from, i) == bs) { + if (out.len == 0) scr_jb_write(&out, "..", 2); + else scr_jb_write(&out, "\\..", 3); + } + } + to_start += last_common_sep; + /* Node reads toOrig.charCodeAt(toStart) — NaN out of range. */ + if (out.len == 0 && scr_str_char_code_at(orig_to, to_start) == bs) ++to_start; + /* toOrig.slice(toStart, toEnd) — "" when the range is empty/inverted. */ + ScrStr *tail = scr_str_slice(orig_to, to_start, to_end); + scr_jb_write(&out, tail->data, tail->len); + scr_str_release(tail); + return scr_jb_finish(&out); +} + +ScrStr *scr_path_win32_relative(ScrStr *from, ScrStr *to) { + if (from->len == to->len && memcmp(from->data, to->data, from->len) == 0) { + return scr_str_new("", 0); + } + /* fromOrig = win32.resolve(from), toOrig = win32.resolve(to). */ + ScrArr *pack = scr_arr_new(SCR_ELEM_STR, 1); + scr_arr_push_ref(pack, scr_str_retain(from)); + ScrStr *rfrom = scr_path_win32_resolve(pack); + scr_arr_release(pack); + pack = scr_arr_new(SCR_ELEM_STR, 1); + scr_arr_push_ref(pack, scr_str_retain(to)); + ScrStr *rto = scr_path_win32_resolve(pack); + scr_arr_release(pack); + + if (rfrom->len == rto->len && memcmp(rfrom->data, rto->data, rfrom->len) == 0) { + scr_str_release(rfrom); + scr_str_release(rto); + return scr_str_new("", 0); + } + /* Node compares the lowercased paths (String.prototype.toLowerCase), + * indexing UTF-16 units. */ + ScrStr *flow = scr_str_to_lower(rfrom); + ScrStr *tlow = scr_str_to_lower(rto); + ScrStr *result = NULL; + if (flow->len == tlow->len && memcmp(flow->data, tlow->data, flow->len) == 0) { + result = scr_str_new("", 0); + } else if (scr_str_utf16_len(rfrom) != scr_str_utf16_len(flow) || + scr_str_utf16_len(rto) != scr_str_utf16_len(tlow)) { + result = scr_path_w32_relative_split(rfrom, rto); + } else { + result = scr_path_w32_relative_units(flow, tlow, rto); + } + scr_str_release(flow); + scr_str_release(tlow); + scr_str_release(rfrom); + scr_str_release(rto); + return result; +} + diff --git a/packages/runtime/test/path.test.ts b/packages/runtime/test/path.test.ts index 79d34c1c3..578c42a30 100644 --- a/packages/runtime/test/path.test.ts +++ b/packages/runtime/test/path.test.ts @@ -7,6 +7,7 @@ import { beforeAll, expect, test } from "vitest"; const execFileAsync = promisify(execFile); const testDir = import.meta.dirname; const bin = join(testDir, "build", "test_path"); +const vendorDir = join(testDir, "../vendor/quickjs-ng"); // Compiles the C-side oracle test once. Built with ASan + the RC audit so // the oracle run also proves the win32 path functions neither leak nor @@ -20,10 +21,19 @@ beforeAll(async () => { "-Wextra", "-fsanitize=address", "-DSCR_RC_AUDIT", + "-I", + vendorDir, "-o", bin, join(testDir, "test_path.c"), join(testDir, "../src/scr_path.c"), + // win32.relative lowercases through the regex unit's case mapping; the + // regex unit brings scr_assert.c and the vendored engine (regex.test.ts). + join(testDir, "../src/scr_path_win32_relative.c"), + join(testDir, "../src/scr_regex.c"), + join(testDir, "../src/scr_assert.c"), + join(vendorDir, "libregexp.c"), + join(vendorDir, "libunicode.c"), // join/resolve take a packed string[]; the array module and its own // dependencies join the link (the array.test.ts set). join(testDir, "../src/scr_string.c"), diff --git a/tests/corpus/js-function-array-tables.cjs b/tests/corpus/js-function-array-tables.cjs new file mode 100644 index 000000000..45a9fad15 --- /dev/null +++ b/tests/corpus/js-function-array-tables.cjs @@ -0,0 +1,24 @@ +'use strict'; +// Test tables that mix functions with arrays of cases, iterated through +// for-of and forEach with destructuring (JavaScript infers a union of +// functions and arrays for each row's items). +function add(a, b) { return a + b; } +function mul(a, b) { return a * b; } + +const table = [ + [add, [[1, 2], [3, 4]]], + [mul, [[5, 6], [0, 9]]], +]; +for (const [fn, cases] of table) { + for (const [a, b] of cases) console.log(fn.name, a, b, fn(a, b)); +} +table.forEach(([fn, cases]) => { + cases.forEach(([a, b]) => console.log('forEach', fn.name, fn.apply(null, [a, b]))); +}); +for (const row of table) { + const [fn, cases] = row; + console.log(typeof fn, Array.isArray(cases), cases.length, fn === add || fn === mul); +} + +const nested = [[Math.max, [[1, 7, 3]]], [Math.min, [[4, -2]]]]; +for (const [pick, lists] of nested) for (const list of lists) console.log(pick(...list)); diff --git a/tests/corpus/path-function-values.cjs b/tests/corpus/path-function-values.cjs new file mode 100644 index 000000000..3a96e2d3f --- /dev/null +++ b/tests/corpus/path-function-values.cjs @@ -0,0 +1,60 @@ +'use strict'; +// node:path namespaces and functions used as values: identity, Node's +// argument validation, and the parse/format/resolve/toNamespacedPath forms. +const path = require('path'); + +const platform = process.platform === 'win32' ? path.win32 : path.posix; +console.log('identity', + path === platform, + require('path/posix') === path.posix, + require('path/win32') === path.win32, + path.posix.win32 === path.win32, + path.win32.posix === path.posix); + +function call(fn, args) { + try { + return JSON.stringify(fn.apply(null, args)); + } catch (e) { + return `${e.name} ${e.code} ${e.message}`; + } +} + +for (const [name, ns] of [['posix', path.posix], ['win32', path.win32]]) { + console.log(`-- ${name}`); + for (const value of [true, 7, null, {}, undefined, []]) { + console.log('join', call(ns.join, ['a', value])); + console.log('normalize', call(ns.normalize, [value])); + console.log('basename suffix', call(ns.basename, [value, 7])); + console.log('basename', call(ns.basename, [value])); + console.log('relative from', call(ns.relative, [value, 'x'])); + console.log('relative to', call(ns.relative, ['x', value])); + console.log('matchesGlob', call(ns.matchesGlob, ['a', value])); + console.log('parse', call(ns.parse, [value])); + console.log('format', call(ns.format, [value])); + console.log('toNamespacedPath', call(ns.toNamespacedPath, [value])); + } + console.log('join none', call(ns.join, [])); + console.log('basename undefined suffix', call(ns.basename, ['/a/b.js', undefined])); + // resolve reads its arguments last first and stops at an absolute path. + console.log('resolve stop', call(ns.resolve, [7, '/abs/x'])); + console.log('resolve bad', call(ns.resolve, ['/abs', 7, 'y'])); + console.log('resolve bad last', call(ns.resolve, ['/abs', 'y', null])); + console.log('parse', call(ns.parse, ['/home/user/dir/file.txt'])); + console.log('format base', call(ns.format, [{ base: 5 }])); + console.log('format dir root', call(ns.format, [{ root: '/', dir: '/', base: 'x' }])); + console.log('format ext', call(ns.format, [{ name: 'x', ext: 'png' }]), + call(ns.format, [{ name: 'x', ext: '.png' }]), call(ns.format, [{ name: 'x', ext: 5 }])); + console.log('format empty', call(ns.format, [{}])); +} +console.log('win32 resolve devices', + call(path.win32.resolve, [null, 'c:\\a', 'd:\\b']), + call(path.win32.resolve, [null, 'c:a', 'd:\\b']), + call(path.win32.resolve, [null, '\\\\server\\share\\x']), + call(path.win32.resolve, ['d:x', null, 'c:\\b'])); +console.log('win32 namespaced', call(path.win32.toNamespacedPath, ['c:\\x']), + call(path.win32.toNamespacedPath, [''])); + +// The same members reached through the namespace object are the same values. +const ns = path.posix; +console.log('members', ns.join === path.posix.join, typeof ns.parse, ns.sep, path.win32.delimiter); +for (const key of ['basename', 'extname']) console.log(key, path[key]('/a/b.txt')); diff --git a/tests/corpus/path-untyped-calls.cjs b/tests/corpus/path-untyped-calls.cjs new file mode 100644 index 000000000..6756f2031 --- /dev/null +++ b/tests/corpus/path-untyped-calls.cjs @@ -0,0 +1,39 @@ +'use strict'; +// Direct node:path calls whose JavaScript arguments are not strings, and +// computed reads of the path namespaces. +const path = require('path'); + +function attempt(label, fn) { + try { + console.log(label, JSON.stringify(fn())); + } catch (e) { + console.log(label, e.name, e.code, e.message); + } +} + +// toNamespacedPath answers a non-string argument as it is. +console.log('toNamespacedPath', path.toNamespacedPath(null), path.toNamespacedPath(100), + path.toNamespacedPath(false), path.posix.toNamespacedPath(), path.toNamespacedPath(path) === path, + path.win32.toNamespacedPath(7), path.win32.toNamespacedPath('')); +attempt('matchesGlob path', () => path.matchesGlob(123, 'foo/bar/baz')); +attempt('matchesGlob pattern', () => path.matchesGlob('foo/bar/baz', 123)); +attempt('basename suffix', () => path.basename('/a/b.js', 7)); +attempt('basename undefined suffix', () => path.basename('/a/b.js', undefined)); +attempt('dirname null', () => path.dirname(null)); +attempt('extname object', () => path.extname({})); +attempt('isAbsolute undefined', () => path.isAbsolute(undefined)); +attempt('relative', () => path.relative('/a', 5)); +attempt('normalize none', () => path.normalize()); +attempt('parse number', () => path.parse(1)); + +for (const family of ['posix', 'win32']) { + const ns = path[family]; + console.log(family, ns.sep, ns.delimiter, path[family].basename('/x/y.txt'), ns === path[family]); + attempt(`${family} computed join`, () => path[family]['join']('a', 'b')); +} +const assert = require('assert'); +assert.strictEqual(path, process.platform === 'win32' ? path.win32 : path.posix); +assert.notStrictEqual(path.posix, path.win32); +assert.strictEqual(require('path/posix'), path.posix); +const key = 'missing'; +console.log('missing member', typeof path[key], typeof path.posix['_makeLong']); diff --git a/tests/corpus/path-win32-relative-case.cjs b/tests/corpus/path-win32-relative-case.cjs new file mode 100644 index 000000000..93a1c9857 --- /dev/null +++ b/tests/corpus/path-win32-relative-case.cjs @@ -0,0 +1,22 @@ +'use strict'; +// path.win32.relative compares paths case-insensitively with +// String.prototype.toLowerCase, including characters whose lowercase form +// changes length (İ) or encoding width (Ⱥ). +const path = require('path'); + +const cases = [ + ['c:\\i̇\\a\\İ', 'c:\\İ\\b\\İ\\test.txt'], + ['c:\\İ\\a', 'c:\\i̇\\a\\b'], + ['c:\\İ\\a\\b', 'c:\\i̇\\a'], + ['c:\\İ', 'C:\\I'], + ['c:\\Ⱥ\\x\\y', 'c:\\ⱥ\\x\\z'], + ['c:\\Ⱥ\\x', 'C:\\ⱥ\\X\\Y\\Z'], + ['C:\\ΣΑΣ\\a', 'c:\\σας\\b'], + ['c:\\straße\\a', 'C:\\STRASSE\\a'], + ['\\\\Server\\Share\\İ\\a', '\\\\server\\share\\i̇\\b'], + ['c:\\a\\b', 'C:\\A\\B'], + ['c:\\a\\🙂\\x', 'c:\\A\\🙂\\y'], +]; +for (const [from, to] of cases) { + console.log(JSON.stringify([from, to]), JSON.stringify(path.win32.relative(from, to))); +} From bece2646e6e34ef5801e48c339e10b8d467c42d0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:12 -0700 Subject: [PATCH 014/193] Pin ICU 78.3, the release Node 24.22.0 bundles Node answers every locale-dependent Intl form from ICU, so a compiled binary that must match it byte for byte has to run the same release over the same data. vendor-inputs.ts pins ICU_VERSION = "78.3" beside the other vendored versions and vendor-inputs.test.ts asserts it against process.versions.icu, the oracle the differential corpus compares with. ICU is not vendored. Its source tarball (icu4c-78.3-sources.tgz, 27,977,255 B) and its prebuilt data zip (icu4c-78.3-data-bin-l.zip, 12,836,659 B) come from unicode-org/icu's official release-78.3 assets, each pinned by version and sha256 beside ICU_VERSION and mirrored in the runtime packs' icu-inputs.mjs, with a unit test that keeps the two identical. The tarball carries ICU's own sources.txt lists, which stay the list of record: icuSourceList() reads them so the archive holds exactly the units ICU's autoconf build compiles. ICU_CXX_TARGET names the glibc floor the archive is built for. The data (icudt78l.dat, 33,107,232 B) is shipped beside the executable, never embedded: 970 source files and 33 MiB of compiled CLDR tables are a download, not a reviewable tree in this repository. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/vendor-inputs.test.ts | 52 ++++++++++++++++++- .../compiler/src/backend/vendor-inputs.ts | 42 +++++++++++++++ .../scripts/icu-inputs.mjs | 24 +++++++++ packages/runtime/vendor/README.md | 23 ++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-pack-common/scripts/icu-inputs.mjs diff --git a/packages/compiler/src/backend/vendor-inputs.test.ts b/packages/compiler/src/backend/vendor-inputs.test.ts index 53e9b8c22..b0025baf8 100644 --- a/packages/compiler/src/backend/vendor-inputs.test.ts +++ b/packages/compiler/src/backend/vendor-inputs.test.ts @@ -2,7 +2,21 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { expect, test } from "vitest"; -import { ZLIB_SOURCES, zlibInputs } from "./vendor-inputs.js"; +import { + ZLIB_SOURCES, + ICU_VERSION, + ICU_SOURCE_URL, + ICU_SOURCE_SHA256, + ICU_SOURCE_SIZE, + ICU_DATA_URL, + ICU_DATA_FILE, + ICU_DATA_SHA256, + ICU_DATA_SIZE, + ICU_DATA_ZIP_SHA256, + ICU_DATA_ZIP_SIZE, + icuSourceList, + zlibInputs, +} from "./vendor-inputs.js"; const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); const zlibDir = join(repoRoot, "packages/runtime/vendor/zlib"); @@ -78,3 +92,39 @@ test("zlib inputs follow Node's deps/zlib/zlib.gyp", () => { expect(wasm.sources).not.toContain("contrib/optimizations/inflate.c"); expect(wasm.defines).toEqual(["HAVE_HIDDEN", "CPU_NO_SIMD"]); }); + +test("the pinned ICU is the release the oracle Node bundles", () => { + // Node's own Intl answers from the ICU it was built with, so the fetched + // release must be that version exactly: the differential corpus compares + // scriptc's ICU output against Node's, byte for byte. + expect(process.versions.icu).toBe(ICU_VERSION); +}); + +test("the compiler's ICU pins match the runtime packs'", async () => { + const pack = (await import( + pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/icu-inputs.mjs")).href + )) as Record; + expect(pack["ICU_VERSION"]).toBe(ICU_VERSION); + expect(pack["ICU_SOURCE_URL"]).toBe(ICU_SOURCE_URL); + expect(pack["ICU_SOURCE_SHA256"]).toBe(ICU_SOURCE_SHA256); + expect(pack["ICU_SOURCE_SIZE"]).toBe(ICU_SOURCE_SIZE); + expect(pack["ICU_DATA_URL"]).toBe(ICU_DATA_URL); + expect(pack["ICU_DATA_FILE"]).toBe(ICU_DATA_FILE); + expect(pack["ICU_DATA_SHA256"]).toBe(ICU_DATA_SHA256); + expect(pack["ICU_DATA_SIZE"]).toBe(ICU_DATA_SIZE); + expect(pack["ICU_DATA_ZIP_SHA256"]).toBe(ICU_DATA_ZIP_SHA256); + expect(pack["ICU_DATA_ZIP_SIZE"]).toBe(ICU_DATA_ZIP_SIZE); + // Both pins name the official release assets for the pinned version. + expect(ICU_SOURCE_URL).toBe( + `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}/icu4c-${ICU_VERSION}-sources.tgz`, + ); + expect(ICU_DATA_URL).toBe( + `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}/icu4c-${ICU_VERSION}-data-bin-l.zip`, + ); + expect(ICU_SOURCE_SHA256).toMatch(/^[0-9a-f]{64}$/); + expect(ICU_DATA_SHA256).toMatch(/^[0-9a-f]{64}$/); +}); + +test("ICU source lists parse to the units a build compiles", () => { + expect(icuSourceList("# a comment\n\n b.cpp \nc.cpp\n")).toEqual(["b.cpp", "c.cpp"]); +}); diff --git a/packages/compiler/src/backend/vendor-inputs.ts b/packages/compiler/src/backend/vendor-inputs.ts index 270815b00..4e87272d8 100644 --- a/packages/compiler/src/backend/vendor-inputs.ts +++ b/packages/compiler/src/backend/vendor-inputs.ts @@ -6,6 +6,48 @@ export const MBEDTLS_VERSION = "3.6.7"; export const ZLIB_VERSION = "1.3.2.1-motley-285e94b"; /** The brotli release Node v24.22.0 bundles (packages/runtime/vendor/brotli). */ export const BROTLI_VERSION = "1.2.0"; +/** ICU4C release 78.3 — the exact version Node v24.22.0 bundles + * (`process.versions.icu` there), from the `release-78.3` tag. ICU is not + * vendored: the build fetches the release's own source tarball and data zip + * from unicode-org/icu's GitHub release, pinning each by version and sha256, + * and caches the download. Its data file icudt78l.dat (33,107,232 B) ships + * beside the executable; see packages/runtime-pack-common/scripts/ + * icu-data.mjs. */ +export const ICU_VERSION = "78.3"; +const ICU_RELEASE_URL = `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}`; +/** The release's source tarball (icu, not icu4j), the only build input that + * is not vendored. */ +export const ICU_SOURCE_URL = `${ICU_RELEASE_URL}/icu4c-${ICU_VERSION}-sources.tgz`; +export const ICU_SOURCE_SHA256 = "3a2e7a47604ba702f345878308e6fefeca612ee895cf4a5f222e7955fabfe0c0"; +export const ICU_SOURCE_SIZE = 27_977_255; +/** The release's prebuilt little-endian data zip. 969 source files are + * reviewable; 33 MiB of compiled CLDR tables are not, so the data is fetched + * too, pinned the same way. */ +export const ICU_DATA_URL = `${ICU_RELEASE_URL}/icu4c-${ICU_VERSION}-data-bin-l.zip`; +export const ICU_DATA_ZIP_SHA256 = + "982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c"; +export const ICU_DATA_ZIP_SIZE = 12_836_659; +export const ICU_DATA_FILE = "icudt78l.dat"; +export const ICU_DATA_SHA256 = "d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b"; +export const ICU_DATA_SIZE = 33_107_232; +/** The glibc target the ICU archive compiles for: the floor the linux-x64-gnu + * runtime pack builds against (packages/runtime-pack-common/scripts/ + * glibc-toolchain.mjs GLIBC_RUNTIME_FLOOR), spelled for Zig's C++ driver. + * ICU is the one C++ archive, and C++ compiled against a newer host glibc + * binds the C23 strtol names (__isoc23_*) that floor forbids. */ +export const ICU_CXX_TARGET = "x86_64-linux-gnu.2.34"; +/** The translation units one of ICU's own source lists names, in its order. + * The fetched tarball carries the lists (`source/common/sources.txt`, + * `source/i18n/sources.txt`) verbatim, so the units a build compiles are + * exactly the units ICU's own autoconf build compiles for this release. + * Entries are bare file names. The pack build reads the same files + * (packages/runtime-pack-common/scripts/icu-data.mjs). */ +export function icuSourceList(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} /** Exact translation-unit membership shared by cache builds and external * source-pack recipes. */ export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const; diff --git a/packages/runtime-pack-common/scripts/icu-inputs.mjs b/packages/runtime-pack-common/scripts/icu-inputs.mjs new file mode 100644 index 000000000..0aef3ba37 --- /dev/null +++ b/packages/runtime-pack-common/scripts/icu-inputs.mjs @@ -0,0 +1,24 @@ +// The pinned ICU4C 78.3 inputs of the runtime packs. ICU is the one build +// input that is not vendored: both the source tarball and the prebuilt data +// zip come from unicode-org/icu's official GitHub release for the pinned +// version, each pinned by version and sha256, and both are cached on disk +// (see icu-data.mjs, which also fetches them). +// packages/compiler/src/backend/vendor-inputs.ts keeps the same pins for the +// source-build path; a unit test checks that the two agree. + +/** Node v24.22.0 bundles ICU 78.3 (`process.versions.icu`). */ +export const ICU_VERSION = "78.3"; + +const RELEASE_URL = `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}`; + +export const ICU_SOURCE_URL = `${RELEASE_URL}/icu4c-${ICU_VERSION}-sources.tgz`; +export const ICU_SOURCE_SHA256 = "3a2e7a47604ba702f345878308e6fefeca612ee895cf4a5f222e7955fabfe0c0"; +export const ICU_SOURCE_SIZE = 27_977_255; + +export const ICU_DATA_URL = `${RELEASE_URL}/icu4c-${ICU_VERSION}-data-bin-l.zip`; +export const ICU_DATA_ZIP_SHA256 = + "982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c"; +export const ICU_DATA_ZIP_SIZE = 12_836_659; +export const ICU_DATA_FILE = "icudt78l.dat"; +export const ICU_DATA_SHA256 = "d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b"; +export const ICU_DATA_SIZE = 33_107_232; diff --git a/packages/runtime/vendor/README.md b/packages/runtime/vendor/README.md index cfb0d196f..faa4b144e 100644 --- a/packages/runtime/vendor/README.md +++ b/packages/runtime/vendor/README.md @@ -79,3 +79,26 @@ The Ed25519 arithmetic behind node:crypto and WebCrypto Ed25519 keys (see packag The only modification to upstream files: `#include "private/x.h"` flattened to `#include "x.h"` in ed25519_ref10.c, sign.c, open.c, keypair.c, hash_sha512_cp.c and ed25519_ref10_fe_25_5.h, so the directory is self-contained. scr_ed25519.c textually includes the .c files and supplies `sodium_memzero`, `sodium_memcmp`, `sodium_is_zero` and `randombytes_buf` (from `arc4random_buf`) in place of libsodium's utils.c and randombytes.c. To update, re-fetch the release tarball, copy the same files, and re-apply that include flattening. The unit compiles only into programs that use asymmetric keys and name Ed25519 in a string literal (the `ed25519` runtime feature); other binaries never compile or link any of this. + +## ICU (fetched, not vendored) + +- Project: https://github.com/unicode-org/icu +- Version: ICU4C 78.3, from the `release-78.3` tag — the exact release Node v24.22.0 bundles (`process.versions.icu`) +- License: Unicode License V3; the sources and the data file ship under the same terms + +The locale/CLDR engine behind the Intl forms only it can answer (see packages/runtime/src/scr_intl.c): the default locale's number and date formats, and word-granularity segmentation. Engine-free Intl forms do not use it and stay in scr_grapheme.c and scr_lib.c. + +ICU is not vendored: 970 source files plus 33 MiB of compiled CLDR tables are a download, not a reviewable tree in this repository. Both build inputs come from unicode-org/icu's official GitHub release for the pinned version, and both are pinned by version and sha256 in packages/compiler/src/backend/vendor-inputs.ts (mirrored in packages/runtime-pack-common/scripts/icu-inputs.mjs for the runtime packs, with a unit test that keeps the two identical): + +- `icu4c-78.3-sources.tgz` (27,977,255 B, sha256 `3a2e7a47604ba702f345878308e6fefeca612ee895cf4a5f222e7955fabfe0c0`), the release's own source tarball: `common/`, `i18n/` and `stubdata/` (the empty data-symbol stub ICU needs when its data ships outside the archive), plus LICENSE. `source/common/sources.txt` and `source/i18n/sources.txt` are the *lists of record*: a build compiles exactly the units ICU's own autoconf build compiles, so `icuSourceList` (vendor-inputs.ts for the source-build path, packages/runtime-pack-common/scripts/icu-data.mjs for the runtime packs) reads them rather than re-deriving the set. +- `icu4c-78.3-data-bin-l.zip` (12,836,659 B, sha256 `982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c`), whose `icudt78l.dat` (33,107,232 B, sha256 `d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b`) is not compiled: it is shipped as `artifacts/icu/icudt78l.dat` and installed beside every executable that links ICU, where the runtime looks first (`$ORIGIN`-style: the executable's own directory), then `ICU_DATA`. + +Each download is cached (the runtime-pack build under `node_modules/.cache/scriptc-icu-*`, the source-build path under the per-user build cache's `vendor/`), verified against its pinned sha256 on every use, and unpacked by the small zip reader in icu-data.mjs (the data) or the system `tar` (the sources). A hash mismatch fails the build with both hashes; an offline build works from a pre-populated cache. To update, bump `ICU_VERSION` with the new release's two assets' sizes and sha256 in vendor-inputs.ts and icu-inputs.mjs. + +Three build properties are worth knowing: + +- **The TUs compile with `zig c++ -target x86_64-linux-gnu.2.34`, not with the host `clang++`.** The host C++ toolchain cannot honor the runtime packs' glibc floor: from glibc 2.38 the C++ feature test macros turn on `__GLIBC_USE_C23_STRTOL`, which renames `strtol`/`strtoul` to `__isoc23_*` (features.h `#undef`s any predefinition), the host `libstdc++` binds those names too, and the pack artifact policy rejects them. Zig's C++ driver compiles for the target's glibc and takes its standard library from the toolchain's own sources, so no object binds a name above the floor. The cost is that an Intl build needs Zig 0.16.0 (its `` answers `zig env`; `ZIG_LIB_DIR` overrides), and that only linux-x64-gnu carries ICU in this slice — the source-build path refuses an explicit cross target. +- **ICU requires RTTI.** `unifiedcache.h` and `ustrenum.cpp` use `typeid` and `uniset_props.cpp` uses `dynamic_cast`, so the design note's `-fno-rtti` is not available. `-fno-exceptions` is, and ICU is written to build without exceptions; it is used. +- **The C++ standard library is compiled from Zig's bundled sources and merged into the archive.** The `libcxx/src` and `libcxxabi/src` units (all but `cxa_personality.cpp`, the one unit that needs exceptions) compile beside ICU's and are archived with them under `cxx-libcxx-`/`cxx-libcxxabi-` members, so the final link stays a plain C link that the pack's `-lm` satisfies. Symbol renaming stays ON (ICU's `_78` suffixes), which is what lets the zero-cost gate (`icuSymbols()` in scripts/zero-cost-compare.mjs) prove that a binary which does not use Intl links no ICU symbol at all. + +The runtime-pack archive is `artifacts/vendor/icu/libscriptc-icu.a`; the source-build path builds the same archive lazily on the first Intl compile into the per-user build cache's `vendor/icu-78.3---/` directory (ensureIcuArchive in packages/compiler/src/backend/vendor-archives.ts). Intl-free binaries compile and link none of it. From f6493a8c8affb23d4fa634c15376914f2294baee Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:13 -0700 Subject: [PATCH 015/193] Answer the first Intl slice from ICU in its own runtime unit scr_intl.c is the ICU-backed unit: Intl.Segmenter word granularity over ICU's UAX #29 iterator, and Number/Date toLocaleString and toLocaleTimeString at the DEFAULT locale (uloc_getDefault, the same answer V8 gets). The engine-free fast paths keep their own units: the table-driven grapheme segmenter in scr_grapheme.c and the embedded en-US number format in scr_lib.c. ICU's data file is looked up beside the executable first ($ORIGIN-style, via /proc/self/exe and its peers) and left to ICU's own search after that (ICU_DATA). A program that uses Intl without the file throws a named Error instead of formatting from nothing. process.config.variables.v8_enable_i18n_support answers 1, as Node does: the read lowers to this unit, so the flag is true of the binary that reports it and no binary that reports it can lack ICU. Two dynamic-tier tags (SEGMENTER_WORD, SEGMENTS_WORD) carry the handle kinds, appended at the end of the list so pinned numbers do not move. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_dyn.h | 2 + packages/runtime/src/scr_intl.c | 586 +++++++++++++++++++++++++++++ packages/runtime/src/scr_intl.h | 43 +++ packages/runtime/src/scr_runtime.h | 1 + 4 files changed, 632 insertions(+) create mode 100644 packages/runtime/src/scr_intl.c create mode 100644 packages/runtime/src/scr_intl.h diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 343930580..4047ad307 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -241,6 +241,8 @@ typedef enum { /* Tags added since the last pin, by name only (see above). */ #define SCR_DYNH_NEW_TAGS(Y) \ Y(NODE_ISLAND) /* a Node island value (scr_node_island_handle.c) */ \ + Y(SEGMENTER_WORD) /* ICU word-granularity Segmenter (scr_intl.c) */ \ + Y(SEGMENTS_WORD) /* its word segmentation result (scr_intl.c) */ \ Y(WS_BYTE_STRATEGY) /* ByteLengthQueuingStrategy (scr_webstreams.c) */ \ Y(WS_COUNT_STRATEGY) /* CountQueuingStrategy (scr_webstreams.c) */ \ Y(WS_RCONTROLLER) /* ReadableStreamDefaultController (scr_webstreams.c) */ \ diff --git a/packages/runtime/src/scr_intl.c b/packages/runtime/src/scr_intl.c new file mode 100644 index 000000000..1659c97da --- /dev/null +++ b/packages/runtime/src/scr_intl.c @@ -0,0 +1,586 @@ +/* Intl over ICU4C (packages/runtime/vendor/icu, version-pinned to the ICU + * Node 24.22.0 bundles). Linked only into programs whose IR selects the + * `icu` runtime feature, so an Intl-free binary carries none of it. + * + * What lowers here (the first slice): + * - Intl.Segmenter({ granularity: "word" }) + * - Number.prototype.toLocaleString() at the default locale + * - Date.prototype.toLocaleString() / toLocaleTimeString() at the default + * locale + * + * The engine-free forms keep their own units: scr_grapheme.c segments + * default grapheme clusters without ICU, and scr_lib.c formats numbers at + * the embedded en-US locale. + * + * ICU needs its data, 31.6 MiB of icudt78l.dat, which scriptc ships BESIDE + * the executable rather than inside it: the runtime looks for it in the + * executable's own directory first, and leaves every other location to + * ICU's own search (ICU_DATA, the configured default directory). A program + * built without the data file throws rather than formatting from nothing. + * + * Symbols: ICU builds with symbol renaming on (the default), so every ICU + * entry point carries the version suffix (_78, _u_strFromUTF8_78, ...). + * That is what lets the zero-cost gate assert that a non-Intl binary links + * no ICU symbol at all. */ +#include "scr_runtime.h" +#include "scr_number.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#elif defined(_WIN32) +#include +#endif + +/* The data file of the pinned ICU release (vendor-inputs.ts ICU_VERSION). */ +#define SCR_INTL_DATA_FILE "icudt78l.dat" + +/* ── data ───────────────────────────────────────────────────────────── + * ICU keeps the mapped data alive for the process lifetime, so the load + * happens exactly once and never unmaps. */ + +static int scr_intl_data_state; /* 0 = not tried, 1 = ready, 2 = failed */ + +/** The running executable's directory, without a trailing separator. */ +static bool scr_intl_exe_dir(char *out, size_t size) { +#if defined(__linux__) + ssize_t got = readlink("/proc/self/exe", out, size - 1); + if (got <= 0 || (size_t)got >= size - 1) return false; + out[got] = '\0'; +#elif defined(__APPLE__) + uint32_t rawSize = (uint32_t)size; + extern int _NSGetExecutablePath(char *buf, uint32_t *bufsize); + if (_NSGetExecutablePath(out, &rawSize) != 0) return false; +#elif defined(_WIN32) + DWORD got = GetModuleFileNameA(NULL, out, (DWORD)size - 1); + if (got == 0 || got >= size - 1) return false; + out[got] = '\0'; +#else + (void)out; + (void)size; + return false; +#endif + char *slash = strrchr(out, '/'); +#if defined(_WIN32) + char *backslash = strrchr(out, '\\'); + if (backslash != NULL && (slash == NULL || backslash > slash)) slash = backslash; +#endif + if (slash == NULL) return false; + *slash = '\0'; + return out[0] != '\0'; +} + +static bool scr_intl_file_exists(const char *path) { + FILE *file = fopen(path, "rb"); + if (file == NULL) return false; + fclose(file); + return true; +} + +/** True once ICU can resolve data: the file beside the executable, else + * whatever ICU's own search finds (ICU_DATA). Never throws; the callers + * do, with the same message. */ +static bool scr_intl_data(void) { + if (scr_intl_data_state != 0) return scr_intl_data_state == 1; + scr_intl_data_state = 2; + char dir[PATH_MAX]; + if (scr_intl_exe_dir(dir, sizeof dir)) { + char path[PATH_MAX]; + if (snprintf(path, sizeof path, "%s/%s", dir, SCR_INTL_DATA_FILE) < (int)sizeof path && + scr_intl_file_exists(path)) { + /* ICU opens /icudt78l.dat itself from here on. */ + u_setDataDirectory(dir); + } + } + /* Probe one ordinary resource: the default locale's decimal format. + * Everything else this unit formats resolves through the same data. */ + UErrorCode status = U_ZERO_ERROR; + UNumberFormat *probe = unum_open(UNUM_DECIMAL, NULL, 0, NULL, NULL, &status); + if (U_FAILURE(status) || probe == NULL) return false; + unum_close(probe); + scr_intl_data_state = 1; + return true; +} + +/** Throw the missing-data Error (Node cannot reach this: its ICU data is + * inside the binary). Callers return NULL with the exception pending. */ +static void scr_intl_no_data(void) { + static const char message[] = + "scriptc: ICU data (icudt78l.dat) was not found next to the executable; this program uses " + "Intl, so ship the runtime pack's icudt78l.dat beside the binary or point ICU_DATA at it"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); +} + +/* ── WTF-8 ↔ UTF-16 ─────────────────────────────────────────────────── + * ScrStr storage is canonical WTF-8 (a lone surrogate is its 3-byte + * generalized form), so both directions preserve lone surrogates and + * every public offset stays a UTF-16 unit count. */ + +static int32_t scr_intl_units(const char *data, size_t len) { + int32_t units = 0; + for (size_t at = 0; at < len;) { + unsigned char byte = (unsigned char)data[at]; + if (byte < 0x80) at += 1; + else if (byte < 0xe0) at += 2; + else if (byte < 0xf0) at += 3; + else { + at += 4; + units++; /* a non-BMP code point is two units */ + } + units++; + } + return units; +} + +/** A UTF-16 copy of `s`, NUL-terminated; *units receives its length. + * Never NULL for a successful call (an empty string allocates one unit). */ +static UChar *scr_intl_to_utf16(const ScrStr *s, int32_t *units) { + const char *data = s->data; + size_t len = s->len; + int32_t count = scr_intl_units(data, len); + UChar *text = malloc(((size_t)count + 1) * sizeof *text); + if (text == NULL) scr_trap("scriptc: out of memory\n"); + int32_t at = 0; + for (size_t pos = 0; pos < len;) { + unsigned char byte = (unsigned char)data[pos]; + uint32_t cp; + size_t size; + if (byte < 0x80) { + cp = byte; + size = 1; + } else if (byte < 0xe0) { + cp = ((uint32_t)(byte & 0x1f) << 6) | ((unsigned char)data[pos + 1] & 0x3fu); + size = 2; + } else if (byte < 0xf0) { + cp = ((uint32_t)(byte & 0x0f) << 12) | (((unsigned char)data[pos + 1] & 0x3fu) << 6) | + ((unsigned char)data[pos + 2] & 0x3fu); + size = 3; + } else { + cp = ((uint32_t)(byte & 0x07) << 18) | (((unsigned char)data[pos + 1] & 0x3fu) << 12) | + (((unsigned char)data[pos + 2] & 0x3fu) << 6) | ((unsigned char)data[pos + 3] & 0x3fu); + size = 4; + } + if (cp > 0xffff) { + text[at++] = (UChar)(0xd800 + ((cp - 0x10000) >> 10)); + text[at++] = (UChar)(0xdc00 + ((cp - 0x10000) & 0x3ff)); + } else { + text[at++] = (UChar)cp; + } + pos += size; + } + text[at] = 0; + *units = count; + return text; +} + +/** A ScrStr (+1) holding the WTF-8 of `count` UTF-16 units, canonical: + * an adjacent high/low pair fuses into its 4-byte form. */ +static ScrStr *scr_intl_from_utf16(const UChar *text, int32_t count) { + size_t capacity = ((size_t)count + 1) * 3; + char *bytes = malloc(capacity); + if (bytes == NULL) scr_trap("scriptc: out of memory\n"); + size_t len = 0; + for (int32_t at = 0; at < count; at++) { + uint32_t cp = text[at]; + if (cp >= 0xd800 && cp <= 0xdbff && at + 1 < count && text[at + 1] >= 0xdc00 && + text[at + 1] <= 0xdfff) { + cp = 0x10000 + ((cp - 0xd800) << 10) + ((uint32_t)text[at + 1] - 0xdc00); + at++; + } + len += scr_wtf8_put(bytes + len, cp); + } + ScrStr *result = scr_str_new(bytes, len); + free(bytes); + return result; +} + +/* ── numbers ──────────────────────────────────────────────────────────── */ + +/** "rounding-mode-half-up .###" — V8's default number format: CLDR's + * pattern for the locale (its group sizes and minimumGroupingDigits), at + * most three fraction digits, half-up rounding. Skeleton text is ASCII. */ +static const char SCR_INTL_NUMBER_SKELETON[] = "rounding-mode-half-up .###"; + +static int32_t scr_intl_skeleton(const char *utf8, UChar *out, int32_t capacity) { + UErrorCode status = U_ZERO_ERROR; + int32_t len = 0; + u_strFromUTF8(out, capacity, &len, utf8, -1, &status); + return U_FAILURE(status) ? -1 : len; +} + +ScrStr *scr_intl_num_format_default(double x) { + if (!scr_intl_data()) { + scr_intl_no_data(); + return NULL; + } + UErrorCode status = U_ZERO_ERROR; + UChar skeleton[64]; + int32_t skeletonLen = + scr_intl_skeleton(SCR_INTL_NUMBER_SKELETON, skeleton, (int32_t)(sizeof skeleton / sizeof *skeleton)); + if (skeletonLen < 0) { + scr_intl_no_data(); + return NULL; + } + /* locale NULL: the default locale, which ICU resolves from the host + * environment exactly as V8's own ICU does. */ + UNumberFormatter *formatter = + unumf_openForSkeletonAndLocale(skeleton, skeletonLen, NULL, &status); + if (U_FAILURE(status) || formatter == NULL) { + scr_intl_no_data(); + return NULL; + } + UFormattedNumber *formatted = unumf_openResult(&status); + if (U_FAILURE(status) || formatted == NULL) { + unumf_close(formatter); + scr_intl_no_data(); + return NULL; + } + unumf_formatDouble(formatter, x, formatted, &status); + int32_t len = 0; + const UChar *text = NULL; + if (U_SUCCESS(status)) { + const UFormattedValue *value = unumf_resultAsValue(formatted, &status); + if (U_SUCCESS(status) && value != NULL) text = ufmtval_getString(value, &len, &status); + } + ScrStr *result = NULL; + if (U_SUCCESS(status) && text != NULL) result = scr_intl_from_utf16(text, len); + unumf_closeResult(formatted); + unumf_close(formatter); + if (result == NULL) scr_intl_no_data(); + return result; +} + +/* ── capability ───────────────────────────────────────────────────────── */ + +double scr_intl_i18n_support(void) { + /* Node's process.config.variables.v8_enable_i18n_support is the number 1; + * so is this binary's, because this unit (and the ICU archive behind it) + * is what answers the read. Nothing here consults ICU itself: the value + * describes the build, not the data file a run happens to find. */ + return 1.0; +} + +/* ── dates ────────────────────────────────────────────────────────────── */ + +/** V8's FormatDateTime replaces U+202F NARROW NO-BREAK SPACE with an + * ordinary space before AM/PM ("Revert ICU 72 change, Part II — + * crbug.com/1414292"), so ICU versions past 72 keep printing what Node + * prints. Applied to the UTF-16 result before the WTF-8 conversion. */ +static void scr_intl_undo_narrow_space(UChar *text, int32_t len) { + for (int32_t at = 0; at < len; at++) { + if (text[at] == 0x202f) text[at] = 0x20; + } +} + +static ScrStr *scr_intl_format_date(double ms, const char *skeletonText) { + /* TimeClip's invalid date: Node's formatters answer the string rather + * than throwing. */ + if (!isfinite(ms)) return scr_str_new("Invalid Date", 12); + if (!scr_intl_data()) { + scr_intl_no_data(); + return NULL; + } + UErrorCode status = U_ZERO_ERROR; + UChar skeleton[32]; + int32_t skeletonLen = + scr_intl_skeleton(skeletonText, skeleton, (int32_t)(sizeof skeleton / sizeof *skeleton)); + if (skeletonLen < 0) { + scr_intl_no_data(); + return NULL; + } + UDateTimePatternGenerator *generator = udatpg_open(NULL, &status); + if (U_FAILURE(status) || generator == NULL) { + scr_intl_no_data(); + return NULL; + } + UChar pattern[128]; + int32_t patternLen = udatpg_getBestPattern( + generator, skeleton, skeletonLen, pattern, (int32_t)(sizeof pattern / sizeof *pattern), &status); + udatpg_close(generator); + if (U_FAILURE(status) || patternLen <= 0) { + scr_intl_no_data(); + return NULL; + } + /* A NULL time zone is ICU's default zone, which is the host's (TZ, else + * /etc/localtime) — the same zone V8's default formatter uses. */ + UDateFormat *format = udat_open(UDAT_PATTERN, UDAT_PATTERN, NULL, NULL, 0, pattern, patternLen, &status); + if (U_FAILURE(status) || format == NULL) { + scr_intl_no_data(); + return NULL; + } + UChar text[256]; + int32_t len = udat_format(format, ms, text, (int32_t)(sizeof text / sizeof *text), NULL, &status); + udat_close(format); + if (U_FAILURE(status) || len < 0) { + scr_intl_no_data(); + return NULL; + } + scr_intl_undo_narrow_space(text, len); + return scr_intl_from_utf16(text, len); +} + +ScrStr *scr_intl_date_locale_string(double ms) { + return scr_intl_format_date(ms, "yMdjms"); +} + +ScrStr *scr_intl_date_locale_time_string(double ms) { + return scr_intl_format_date(ms, "jms"); +} + +/* ── Intl.Segmenter({ granularity: "word" }) ──────────────────────────── */ + +typedef struct { + size_t rc; + char *locale; /* NULL: the default locale */ +} ScrIcuWordSegmenter; + +static void *scr_icu_word_segmenter_retain(void *ptr) { + ScrIcuWordSegmenter *value = ptr; + value->rc++; + return value; +} + +static void scr_icu_word_segmenter_release(void *ptr) { + ScrIcuWordSegmenter *value = ptr; + if (--value->rc) return; + free(value->locale); + free(value); +} + +typedef struct { + size_t rc; + ScrStr *input; /* retained */ + UChar *text; /* UTF-16 mirror of input, NUL-terminated */ + int32_t units; + char *locale; +} ScrIcuWordSegments; + +static void *scr_icu_word_segments_retain(void *ptr) { + ScrIcuWordSegments *value = ptr; + value->rc++; + return value; +} + +static void scr_icu_word_segments_release(void *ptr) { + ScrIcuWordSegments *value = ptr; + if (--value->rc) return; + scr_str_release(value->input); + free(value->text); + free(value->locale); + free(value); +} + +static UBreakIterator *scr_icu_word_iterator(const ScrIcuWordSegments *segments) { + /* A word segmenter need not have formatted anything yet: this iterator + * can be the program's FIRST ICU use, so the data directory must be + * resolved here too, before ubrk_open reads its break rules. */ + if (!scr_intl_data()) { + scr_intl_no_data(); + return NULL; + } + UErrorCode status = U_ZERO_ERROR; + UBreakIterator *iter = + ubrk_open(UBRK_WORD, segments->locale, segments->text, segments->units, &status); + if (U_FAILURE(status) || iter == NULL) { + scr_intl_no_data(); + return NULL; + } + return iter; +} + +/* One segment record: { segment, index, input, isWordLike }. Node's own + * property order (CreateSegmentDataObject creates them in this order), and + * isWordLike only for word granularity. */ +static ScrDyn *scr_icu_word_record( + const ScrIcuWordSegments *segments, int32_t start, int32_t end, bool wordLike) { + ScrDyn *record = scr_dyn_new_obj(); + ScrStr *segment = scr_intl_from_utf16(segments->text + start, end - start); + scr_dyn_obj_set(record, "segment", 7, scr_dyn_new_str(segment)); + scr_str_release(segment); + scr_dyn_obj_set(record, "index", 5, scr_dyn_new_num((double)start)); + scr_dyn_obj_set(record, "input", 5, scr_dyn_new_str(segments->input)); + scr_dyn_obj_set(record, "isWordLike", 10, scr_dyn_new_bool(wordLike)); + return record; +} + +static ScrDyn *scr_icu_word_iter_pack(void *ptr) { + ScrIcuWordSegments *segments = ptr; + ScrDyn *result = scr_dyn_new_arr(); + UBreakIterator *iter = scr_icu_word_iterator(segments); + if (iter == NULL) return NULL; + int32_t start = ubrk_first(iter); + for (int32_t end = start < 0 ? -1 : ubrk_next(iter); start >= 0 && end >= 0; + start = end, end = ubrk_next(iter)) { + bool wordLike = ubrk_getRuleStatus(iter) != UBRK_WORD_NONE; + scr_dyn_arr_push(result, scr_icu_word_record(segments, start, end, wordLike)); + } + ubrk_close(iter); + return result; +} + +static ScrDyn *scr_icu_word_box_segments(ScrStr *input, const char *locale); + +static ScrDyn *scr_icu_word_segments_containing(ScrIcuWordSegments *segments, double index) { + index = isnan(index) ? 0 : trunc(index); + if (!(index >= 0) || !isfinite(index) || index >= (double)segments->units) + return scr_dyn_retain(scr_dyn_undefined()); + UBreakIterator *iter = scr_icu_word_iterator(segments); + if (iter == NULL) return NULL; + int32_t start = ubrk_first(iter); + ScrDyn *record = NULL; + for (int32_t end = start < 0 ? -1 : ubrk_next(iter); start >= 0 && end >= 0; + start = end, end = ubrk_next(iter)) { + if (index < (double)end) { + bool wordLike = ubrk_getRuleStatus(iter) != UBRK_WORD_NONE; + record = scr_icu_word_record(segments, start, end, wordLike); + break; + } + } + ubrk_close(iter); + return record != NULL ? record : scr_dyn_retain(scr_dyn_undefined()); +} + +static ScrDyn *scr_icu_word_segmenter_invoke(void *ptr, ScrDyn *self, const char *method, + ScrDyn *const *args, size_t argc, const char *what) { + (void)self; + ScrIcuWordSegmenter *value = ptr; + if (strcmp(method, "segment") == 0) { + ScrStr *input = scr_dyn_string_coerce_js(argc ? args[0] : scr_dyn_undefined()); + if (input == NULL) return NULL; + ScrDyn *boxed = scr_icu_word_box_segments(input, value->locale); + scr_str_release(input); + return boxed; + } + if (strcmp(method, "resolvedOptions") == 0) { + static const char message[] = "Intl.Segmenter locale negotiation is not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + return NULL; + } + ScrJsonBuf message; + scr_jb_init(&message); + scr_jb_puts(&message, what); + scr_jb_puts(&message, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message)); + return NULL; +} + +static ScrDyn *scr_icu_word_segments_invoke(void *ptr, ScrDyn *self, const char *method, + ScrDyn *const *args, size_t argc, const char *what) { + (void)self; + ScrIcuWordSegments *value = ptr; + if (strcmp(method, "containing") == 0) { + double index; + if (!scr_dyn_number_coerce_js(argc ? args[0] : scr_dyn_undefined(), &index)) return NULL; + return scr_icu_word_segments_containing(value, index); + } + ScrJsonBuf message; + scr_jb_init(&message); + scr_jb_puts(&message, what); + scr_jb_puts(&message, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message)); + return NULL; +} + +static ScrDyn *scr_icu_segmenter_get(void *ptr, const char *key, size_t len) { + (void)ptr; + /* The same stance as the grapheme segmenter (scr_grapheme.c): a stored + * method has no representation, so reading one throws rather than + * yielding undefined. Only the segmenter's own methods: a Segments + * object answers the read as an ordinary missing property would. */ + if ((len == 7 && memcmp(key, "segment", len) == 0) || + (len == 15 && memcmp(key, "resolvedOptions", len) == 0)) { + static const char message[] = "stored Intl.Segmenter methods are not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + } + return NULL; +} + +static ScrDyn *scr_icu_segments_get(void *ptr, const char *key, size_t len) { + (void)ptr; + if (len == 10 && memcmp(key, "containing", len) == 0) { + static const char message[] = "stored Intl.Segmenter methods are not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + } + return NULL; +} + +static bool scr_icu_segmenter_set(void *ptr, const char *key, size_t len, const ScrDyn *value) { + (void)ptr; + (void)key; + (void)len; + (void)value; + return false; +} + +static void scr_icu_word_segmenter_ops_install(void) { + static const ScrDynHandleOps segmenter = { + .cls = "Segmenter", + .retain = scr_icu_word_segmenter_retain, + .release = scr_icu_word_segmenter_release, + .invoke = scr_icu_word_segmenter_invoke, + .get = scr_icu_segmenter_get, + .set = scr_icu_segmenter_set, + }; + static const ScrDynHandleOps segments = { + .cls = "Segments", + .retain = scr_icu_word_segments_retain, + .release = scr_icu_word_segments_release, + .invoke = scr_icu_word_segments_invoke, + .get = scr_icu_segments_get, + .set = scr_icu_segmenter_set, + .iter_pack = scr_icu_word_iter_pack, + }; + scr_dyn_handle_install(SCR_DYNH_SEGMENTER_WORD, &segmenter); + scr_dyn_handle_install(SCR_DYNH_SEGMENTS_WORD, &segments); +} + +static ScrDyn *scr_icu_word_box_segments(ScrStr *input, const char *locale) { + ScrIcuWordSegments *value = calloc(1, sizeof *value); + if (value == NULL) scr_trap("scriptc: out of memory\n"); + value->rc = 1; + value->input = scr_str_retain(input); + value->text = scr_intl_to_utf16(input, &value->units); + if (locale != NULL) { + size_t len = strlen(locale); + value->locale = malloc(len + 1); + if (value->locale == NULL) scr_trap("scriptc: out of memory\n"); + memcpy(value->locale, locale, len + 1); + } + ScrDyn *boxed = scr_dyn_new_handle(value, SCR_DYNH_SEGMENTS_WORD); + scr_icu_word_segments_release(value); + return boxed; +} + +ScrDyn *scr_intl_segmenter_word_new(ScrStr *locale) { + scr_icu_word_segmenter_ops_install(); + ScrIcuWordSegmenter *value = calloc(1, sizeof *value); + if (value == NULL) scr_trap("scriptc: out of memory\n"); + value->rc = 1; + /* The empty tag is the absent locale: ICU's NULL selects the default + * locale (uloc_getDefault()), which is what V8 negotiates too, while an + * empty locale name would select root. */ + if (locale != NULL && locale->len > 0) { + size_t len = locale->len; + value->locale = malloc(len + 1); + if (value->locale == NULL) scr_trap("scriptc: out of memory\n"); + memcpy(value->locale, locale->data, len); + value->locale[len] = '\0'; + } + ScrDyn *boxed = scr_dyn_new_handle(value, SCR_DYNH_SEGMENTER_WORD); + scr_icu_word_segmenter_release(value); + return boxed; +} diff --git a/packages/runtime/src/scr_intl.h b/packages/runtime/src/scr_intl.h new file mode 100644 index 000000000..61f4820d5 --- /dev/null +++ b/packages/runtime/src/scr_intl.h @@ -0,0 +1,43 @@ +/* scriptc runtime — Intl over ICU (scr_intl.c). Included by scr_runtime.h, + * which says where new declarations go. */ +#ifndef SCR_INTL_H +#define SCR_INTL_H + +#include "scr_string.h" + +/* ── Intl over ICU4C (scr_intl.c) ───────────────────────────────────── + * Compiled and linked — with the vendored ICU archive — only into + * programs whose IR selects the `icu` runtime feature (the `icu.*` + * libCalls below). Every other program links none of it: the engine-free + * fast paths stay in scr_grapheme.c (default grapheme clusters) and + * scr_lib.c (numbers at the embedded en-US locale). + * + * Node answers these forms from ICU's DATA, which is a 31.6 MiB file + * (icudt78l.dat) shipped beside the executable, never embedded. */ + +/* Number.prototype.toLocaleString() at the default locale: the same + * formatter engine V8 uses (icu::number::LocalizedNumberFormatter) with + * CLDR's default decimal pattern, group sizes and grouping thresholds, + * and half-up rounding to three fraction digits. +1 result. */ +ScrStr *scr_intl_num_format_default(double x); + +/* process.config.variables.v8_enable_i18n_support: Node's own answer is the + * number 1 (it bundles ICU). A compiled binary answers the same 1, and the + * read is what links ICU — so the flag is true of the binary that reports + * it, and no binary that reports it can lack ICU. */ +double scr_intl_i18n_support(void); + +/* Date.prototype.toLocaleString() and toLocaleTimeString() at the default + * locale: ICU's best pattern for the skeleton V8 asks for ("yMdjms" / + * "jms") over `ms` milliseconds in the host time zone, with V8's U+202F → + * U+0020 replacement ("Revert ICU 72 change — crbug.com/1414292"). An + * invalid date (NaN) answers "Invalid Date". +1 result. */ +ScrStr *scr_intl_date_locale_string(double ms); +ScrStr *scr_intl_date_locale_time_string(double ms); + +/* Intl.Segmenter({ granularity: "word" }), ICU's UAX #29 word break + * iterator at `locale` (NULL: the default locale). Records carry + * `isWordLike`, as Node's word segments do. +1 handle. */ +ScrDyn *scr_intl_segmenter_word_new(ScrStr *locale); + +#endif /* SCR_INTL_H */ diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index f828f31f6..e3578398e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -80,5 +80,6 @@ #include "scr_wasm_abi.h" #include "scr_wasm.h" #include "scr_node_island.h" +#include "scr_intl.h" #endif /* SCR_RUNTIME_H */ From 59b722703472d0cb3b823f6fd9fecc4763177808 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:14 -0700 Subject: [PATCH 016/193] Build the ICU archive from the fetched sources on the source-build path Builds that compile the runtime from source (sanitized builds, and links that do not start from a program object) need the same ICU code the pack ships, so ensureIcuArchive compiles it once per flavor and caches it like the mbedTLS archive. Its sources are the pinned release tarball, fetched (verified against its sha256) into the same per-user build cache and reused from there, so an offline build works from a populated cache. The recipe is Zig's C++ driver at the packs' glibc target: host C++ would turn on the C23 strtol names (__isoc23_*) that the artifact policy forbids, and libstdc++ binds them too. Zig's bundled libc++ and libc++abi sources compile beside ICU's and are merged into the archive, so the final link stays a plain C link with the pack's -lm. ICU requires RTTI (typeid, dynamic_cast), so the design's -fno-rtti is not available; -fno-exceptions is, and cxa_personality.cpp (the exception personality routine) is the one libc++abi unit left out. Symbol renaming stays on (the _78 suffixes), which is what makes ICU linkage machine-checkable. The unit args add the fetched include paths, and scr_intl.c joins the optional units only when intl is selected. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/native/contracts.ts | 6 + .../compiler/src/backend/native/executable.ts | 41 +++ .../src/backend/native/runtime-inputs.ts | 37 ++- .../compiler/src/backend/native/vendor.ts | 3 + .../compiler/src/backend/vendor-archives.ts | 308 +++++++++++++++++- 5 files changed, 384 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..17ba54345 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -83,6 +83,12 @@ export interface CcOptions { /** The program uses node:zlib's Brotli forms (with zlib): compiles * scr_brotli.c and links the vendored brotli archive. */ brotli?: boolean; + /** The program uses an Intl form that only ICU can answer (the `icu.*` + * libCalls: default-locale number/date formats, word segmentation): + * compiles scr_intl.c and links the ICU archive, and the + * executable gets icudt78l.dat next to it. Every engine-free form keeps + * linking none of it. */ + intl?: boolean; /** The program uses node:assert (index.ts detects assert.* libCalls on * the IR): compiles scr_assert.c — the zlib gating precedent, so * assert-free binaries keep their exact size class. scr_regex.c calls diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..2ca1ff714 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -84,6 +84,9 @@ import { vendorBuildCacheRoot, tlsArchivePath, brotliArchivePath, + ensureIcuSources, + icuArchivePath, + ensureIcuArchive, lreObjectPaths, zlibObjectPaths, stageVendorInputs, @@ -124,6 +127,9 @@ interface RuntimeUnitContext { zlibObjects: readonly string[]; tlsArchive: string | null; brotliArchive: string | null; + icuArchive: string | null; + /** The fetched ICU release root, for scr_intl.c's headers. */ + icuSourceDir: string | null; } /** An optional runtime input: runtime sources compiled when `when` holds, @@ -245,6 +251,23 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ] : [], ), + // Intl over ICU (scr_intl.c): ICU's C API only, at the default locale, + // reading its data file from beside the executable. The ICU headers are + // ICU's own source tree (common/unicode/*.h for the core API, and + // i18n/unicode/*.h for udat.h and unumberformatter.h), so the include + // paths are vendor inputs rather than a copied header set. + unitArgs((u) => + u.icuSourceDir === null || u.icuArchive === null + ? [] + : [ + "-I", + join(u.icuSourceDir, "source", "common"), + "-I", + join(u.icuSourceDir, "source", "i18n"), + u.rt(join(u.rtDir, "scr_intl.c")), + u.icuArchive, + ], + ), unit((u) => u.opts.events, "scr_events.c", "scr_readline.c"), // readline's terminal mode: rl.terminal sets the regex unit it // measures with. @@ -447,6 +470,7 @@ export async function compileCInternal( const pki = opts.pki ?? false; const mbedtls = tls || cipher || ecdh || dh || pki; const brotli = (opts.zlib ?? false) && opts.brotli === true; + const icu = opts.intl ?? false; const driver = resolveCc(); const darwinDebugSymbols = needsDarwinDebugSymbols( targetPlatform(driver), @@ -689,6 +713,8 @@ export async function compileCInternal( let brotliArchive = brotli ? brotliArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot) : null; + let icuArchive = icu ? icuArchivePath(driver, vendorBuildIdentity, vendorCacheRoot) : null; + let icuSourceDir: string | null = null; // ours would collide; see scr_regex.c) — the standalone objects are for // static builds only. @@ -743,6 +769,19 @@ export async function compileCInternal( : await stageVendorInputs(materialize, join(stageRoot, "brotli")); brotliArchive = paths[0]!; } + if (icu) { + const cachedArchive = icuArchivePath(driver, vendorBuildIdentity, materializeCacheRoot); + protectCachedArtifact(cacheWarmPaths, cachedArchive); + const materialize = async (): Promise => [ + await ensureIcuArchive(driver, vendorBuildIdentity, materializeCacheRoot), + ]; + const paths = + stageRoot === undefined + ? await materialize() + : await stageVendorInputs(materialize, join(stageRoot, "icu")); + icuArchive = paths[0]!; + icuSourceDir = await ensureIcuSources(materializeCacheRoot); + } if (regex) { for (const object of lreObjectPaths( sanitize, @@ -830,6 +869,8 @@ export async function compileCInternal( zlibObjects, tlsArchive, brotliArchive, + icuArchive, + icuSourceDir, }), // A .ll program TU (the LLVM backend) deliberately carries no target // triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts index 60f1a8960..4fe8100da 100644 --- a/packages/compiler/src/backend/native/runtime-inputs.ts +++ b/packages/compiler/src/backend/native/runtime-inputs.ts @@ -3,7 +3,13 @@ import { createRequire } from "node:module"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { BROTLI_VERSION, MBEDTLS_VERSION, QJS_COMMIT, ZLIB_VERSION } from "../vendor-inputs.js"; +import { + BROTLI_VERSION, + ICU_VERSION, + MBEDTLS_VERSION, + QJS_COMMIT, + ZLIB_VERSION, +} from "../vendor-inputs.js"; import { stableTestMemo } from "./session.js"; const implementationPath = fileURLToPath(import.meta.url); @@ -139,6 +145,7 @@ async function runtimeFingerprintFresh(rtDir: string): Promise { .update(MBEDTLS_VERSION) .update(ZLIB_VERSION) .update(BROTLI_VERSION) + .update(ICU_VERSION) .update("\0backend-recipe\0"); for (const path of NATIVE_RECIPE_IMPLEMENTATION_PATHS) { h.update(basename(path)) @@ -159,19 +166,29 @@ async function runtimeFingerprintFresh(rtDir: string): Promise { return h.digest("hex"); } +interface RuntimeFingerprintGroup { + label: string; + dir: string; +} + async function runtimeFingerprintInputGroups( rtDir: string, ): Promise<{ label: string; dir: string; names: string[] }[]> { return Promise.all( - [ - { label: "runtime", dir: rtDir }, - { label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") }, - { label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") }, - { label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") }, - { label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") }, - { label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") }, - { label: "curl", dir: join(rtDir, "..", "vendor", "curl") }, - ].map(async (group) => { + ( + [ + { label: "runtime", dir: rtDir }, + { label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") }, + { label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") }, + { label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") }, + { label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") }, + { label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") }, + // ICU is fetched, not vendored: its pinned version and sha256 key the + // archive's cache path (vendor-archives.ts icuArchivePath), so it is + // not part of the runtime tree's fingerprint. + { label: "curl", dir: join(rtDir, "..", "vendor", "curl") }, + ] satisfies RuntimeFingerprintGroup[] + ).map(async (group) => { const names = (await nativeSourceFiles(group.dir, true)) .map((path) => relative(group.dir, path)) .sort(); diff --git a/packages/compiler/src/backend/native/vendor.ts b/packages/compiler/src/backend/native/vendor.ts index b6084d5c4..f7b7dacee 100644 --- a/packages/compiler/src/backend/native/vendor.ts +++ b/packages/compiler/src/backend/native/vendor.ts @@ -9,6 +9,9 @@ export const { vendorBrotliDir, brotliArchivePath, ensureBrotliArchive, + ensureIcuSources, + icuArchivePath, + ensureIcuArchive, vendorBuildCacheRoot, vendorCacheTargetFlavor, vendorCacheBuildIdentity, diff --git a/packages/compiler/src/backend/vendor-archives.ts b/packages/compiler/src/backend/vendor-archives.ts index 363d14b08..a68294ae5 100644 --- a/packages/compiler/src/backend/vendor-archives.ts +++ b/packages/compiler/src/backend/vendor-archives.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { availableParallelism, tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -11,9 +11,32 @@ import { validCachedFile, } from "./build-cache.js"; import type { CcDriver } from "./native/driver.js"; +import { NativeCodegenError } from "./native-codegen-core.js"; const execFileAsync = promisify(execFile); +/** One C++ translation unit of the ICU archive: the source, the archive member + * name it compiles to, and the flags of its group. */ +interface IcuUnit { + path: string; + member: string; + flags: string[]; + define?: string; +} + +/** Zig's library directory, which carries the libc++/libc++abi sources the + * ICU archive merges: `ZIG_LIB_DIR` when the caller pins one, else the pinned + * toolchain's own answer. */ +async function zigLibDir(): Promise { + const pinned = process.env["ZIG_LIB_DIR"]; + if (pinned !== undefined && pinned !== "") return pinned; + const { stdout } = await execFileAsync("zig", ["env"]); + const match = /\.lib_dir\s*=\s*"([^"]+)"/.exec(stdout); + if (match === null) + throw new Error("Intl needs Zig 0.16.0's C++ toolchain: `zig env` named no lib_dir"); + return match[1]!; +} + /** 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"]; @@ -24,13 +47,33 @@ function vendorObjectName(source: string): string { return source.replaceAll("/", "-").replace(/\.c$/, ".o"); } +/** The sha256 of a buffer, as lowercase hex. */ +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Whether `path` is a non-empty file. */ +async function presentFile(path: string): Promise { + try { + return (await stat(path)).size > 0; + } catch { + return false; + } +} + import { QJS_COMMIT, MBEDTLS_VERSION, ZLIB_VERSION, BROTLI_VERSION, + ICU_VERSION, + ICU_SOURCE_URL, + ICU_SOURCE_SHA256, + ICU_SOURCE_SIZE, + ICU_CXX_TARGET, LRE_SOURCES, ZLIB_SOURCES, + icuSourceList, zlibInputs, } from "./vendor-inputs.js"; export { @@ -38,6 +81,7 @@ export { MBEDTLS_VERSION, ZLIB_VERSION, BROTLI_VERSION, + ICU_VERSION, LRE_SOURCES, ZLIB_SOURCES, } from "./vendor-inputs.js"; @@ -532,9 +576,271 @@ export function createVendorArchives(context: VendorArchiveContext) { return archive; } + /** The fetched ICU release root (`icu/`, holding `source/` and LICENSE), + * downloaded once per cache root and reused after that. ICU is not + * vendored: the release's own source tarball is pinned by version and + * sha256 in vendor-inputs.ts, verified on every use, and extracted by the + * system `tar` as the libnode build does. An existing extracted tree is + * enough, so an offline build works from a pre-populated cache. */ + const icuSourcesByCacheRoot = new Map>(); + function ensureIcuSources(cacheRoot: string = vendorBuildCacheRoot()): Promise { + const pending = icuSourcesByCacheRoot.get(cacheRoot); + if (pending !== undefined) return pending; + const pendingFetch = (async (): Promise => { + const tree = join(cacheRoot, `icu4c-${ICU_VERSION}-source`); + if (await presentFile(join(tree, "source", "common", "sources.txt"))) return tree; + const tarball = join(cacheRoot, `icu4c-${ICU_VERSION}-sources.tgz`); + let archive: Buffer; + try { + archive = await readFile(tarball); + if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256) + throw new Error("cached ICU source tarball does not match its pin"); + } catch { + let response: Response; + try { + response = await fetch(ICU_SOURCE_URL); + } catch (cause) { + throw new Error( + `could not download ICU's sources (${ICU_SOURCE_URL}); ` + + `populate ${tarball} or allow network access`, + { cause }, + ); + } + if (!response.ok) + throw new Error( + `could not download ICU's sources (${ICU_SOURCE_URL}): ` + + `${response.status} ${response.statusText}`, + ); + archive = Buffer.from(await response.arrayBuffer()); + if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256) + throw new Error( + `ICU source tarball from ${ICU_SOURCE_URL} is ${archive.length} bytes with sha256 ` + + `${sha256(archive)}, expected ${ICU_SOURCE_SIZE} bytes with sha256 ` + + `${ICU_SOURCE_SHA256}`, + ); + await mkdir(cacheRoot, { recursive: true }); + await writeFile(tarball, archive); + } + await mkdir(cacheRoot, { recursive: true }); + const stage = await mkdtemp(join(cacheRoot, ".icu-sources-")); + try { + await execFileAsync("tar", ["-xzf", tarball, "-C", stage]); + if (!(await presentFile(join(stage, "icu", "source", "common", "sources.txt")))) + throw new Error(`ICU source tarball ${ICU_SOURCE_URL} does not hold icu/source`); + await rm(tree, { recursive: true, force: true }); + await rename(join(stage, "icu"), tree); + } finally { + await rm(stage, { recursive: true, force: true }); + } + return tree; + })(); + icuSourcesByCacheRoot.set(cacheRoot, pendingFetch); + pendingFetch.catch(() => icuSourcesByCacheRoot.delete(cacheRoot)); + return pendingFetch; + } + + /** The archive is the same for sanitized and plain builds: it is never + * instrumented (see ensureIcuArchive), so neither the path nor the build + * carries a sanitizer flavor. */ + function icuArchivePath( + driver: CcDriver, + buildIdentity: string, + cacheRoot: string = vendorBuildCacheRoot(), + ): string { + const flavor = `plain-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`; + return join(cacheRoot, `icu-${ICU_VERSION}-${flavor}`, "libicu.a"); + } + + /** The ICU archive for one flavor, compiled lazily on the first + * Intl-using build (~470 C++ TUs, parallelized) and cached under + * /vendor/icu----/ + * libicu.a with the mbedTLS archive's atomic-rename publish. + * + * Three things make this archive different from every other vendored one: + * + * - It is C++, so the pack's C driver cannot compile it: the TUs (and the + * standard library they use) build with Zig's C++ driver for the glibc + * target the runtime packs pin. That target is the point. In C++ mode, + * glibc 2.38+ headers redirect strtol/strtoul to the C23 __isoc23_* + * names, and a host libstdc++ binds those names too, so a host-compiled + * C++ archive would raise the glibc floor above the packs'. Zig's + * bundled libc++ and libc++abi compile from the toolchain's own sources + * for the target and bind the target's names only. + * - ICU requires RTTI (`typeid` in unifiedcache.h and ustrenum.cpp, + * `dynamic_cast` in uniset_props.cpp), so the design note's + * `-fno-rtti` is not available; `-fno-exceptions` is, and ICU, libc++, + * and libc++abi all build so. libc++abi's Itanium personality routine + * (cxa_personality.cpp) is the one unit that needs exceptions, so it is + * left out: nothing can throw into it. + * - Both the standard library and the C++ runtime it needs are merged INTO + * the archive, so the final link stays C-only: a plain `clang`/`zig` C + * link with the runtime pack's `-lm` resolves every undefined symbol. + * Members are prefixed by source (`cxx-libcxx-`, `cxx-libcxxabi-`) so + * their names stay unique. + * + * ICU builds with symbol renaming on (version suffixes `_78`), which is + * what lets the zero-cost gate assert that only Intl programs link ICU + * symbols. + * + * Sanitized builds link this same uninstrumented archive. AddressSanitizer + * cannot instrument it: Zig's bundled libc++ compiles with + * `-fsanitize=address` only against a `sanitizer/asan_interface.h` its + * tree does not ship, and compiling ~600 C++ TUs again for the sanitizer + * lane would add minutes to the first Intl program of every sanitized + * build. The runtime's own ICU code (scr_intl.c) still compiles with the + * sanitizer, and AddressSanitizer's interceptors cover ICU's allocations, + * so a sanitized Intl program reports heap errors from ICU data as it + * does everywhere else — the ICU code itself is simply not instrumented. */ + async function ensureIcuArchive( + driver: CcDriver, + buildIdentity: string, + cacheRoot: string = vendorBuildCacheRoot(), + ): Promise { + // The archive is host-targeted ELF for the packs' glibc floor; a cross + // target would need a Zig C++ link recipe this slice has not validated. + // Both refusals are compile diagnostics (SC3002), not internal errors: + // an Intl program on any other host or target is a documented "not + // here yet", the same answer the missing runtime pack gives. + if (driver.target !== null) + throw new NativeCodegenError( + "SC3002", + `Intl needs ICU, which scriptc builds for the host's linux-x64 gnu target only: ` + + `the ${driver.target} target is not supported yet`, + ); + if (process.platform !== "linux" || process.arch !== "x64") + throw new NativeCodegenError( + "SC3002", + "Intl needs ICU, which this compiler builds for linux-x64 only: " + + `the ${process.platform}-${process.arch} host is not supported yet`, + ); + const flavor = `plain-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`; + const archive = icuArchivePath(driver, buildIdentity, cacheRoot); + if (await validVendorArtifact(archive)) return archive; + + await mkdir(cacheRoot, { recursive: true }); + const buildDir = await mkdtemp(join(tmpdir(), `scriptc-vendor-icu-${flavor}-`)); + // The tarball extracts to `icu/source/...` with ICU's LICENSE beside it. + const vendor = join(await ensureIcuSources(cacheRoot), "source"); + const libDir = await zigLibDir(); + const cxx = ["zig", "c++", "-target", ICU_CXX_TARGET]; + const optimize = ["-Os"]; + const commonFlags = [ + "-std=c++17", + ...optimize, + "-fno-exceptions", + "-ffunction-sections", + "-fdata-sections", + ]; + const icuFlags = [ + ...commonFlags, + "-DU_STATIC_IMPLEMENTATION", + "-DU_ATTRIBUTE_DEPRECATED=", + "-DNDEBUG", + "-Wno-deprecated-declarations", + "-I", + join(vendor, "common"), + "-I", + join(vendor, "i18n"), + ]; + // libc++ 20 sources need C++20 (`constinit`, ranges) and libc++'s + // charconv reads a header from `libcxx/libc`. + const cxxLibraryFlags = [ + "-std=c++23", + ...optimize, + "-fno-exceptions", + "-ffunction-sections", + "-fdata-sections", + "-I", + join(libDir, "libcxx", "include"), + "-I", + join(libDir, "libcxx", "src"), + "-I", + join(libDir, "libcxx", "libc"), + "-I", + join(libDir, "libcxxabi", "include"), + "-D_LIBCPP_BUILDING_LIBRARY", + "-D_LIBCXXABI_BUILDING_LIBRARY", + "-DLIBCXX_BUILDING_LIBCXXABI", + "-Wno-unknown-warning-option", + ]; + const cxxLibraryUnits = async ( + subdirectory: string, + memberPrefix: string, + skip: ReadonlySet, + ): Promise => { + const sources = (await readdir(join(libDir, subdirectory, "src"))) + .filter((name) => name.endsWith(".cpp") && !skip.has(name)) + .sort(); + return sources.map((source) => ({ + path: join(libDir, subdirectory, "src", source), + member: `${memberPrefix}${source}`, + flags: cxxLibraryFlags, + })); + }; + try { + const units: IcuUnit[] = [ + ...icuSourceList(await readFile(join(vendor, "common", "sources.txt"), "utf8")).map( + (source) => ({ + path: join(vendor, "common", source), + member: `common-${source}`, + flags: icuFlags, + define: "U_COMMON_IMPLEMENTATION", + }), + ), + { + path: join(vendor, "stubdata", "stubdata.cpp"), + member: "stubdata-stubdata.cpp", + flags: icuFlags, + define: "U_COMMON_IMPLEMENTATION", + }, + ...icuSourceList(await readFile(join(vendor, "i18n", "sources.txt"), "utf8")).map( + (source) => ({ + path: join(vendor, "i18n", source), + member: `i18n-${source}`, + flags: icuFlags, + define: "U_I18N_IMPLEMENTATION", + }), + ), + ...(await cxxLibraryUnits("libcxx", "cxx-libcxx-", new Set())), + ...(await cxxLibraryUnits("libcxxabi", "cxx-libcxxabi-", new Set(["cxa_personality.cpp"]))), + ]; + const width = availableParallelism(); + for (let i = 0; i < units.length; i += width) { + await Promise.all( + units.slice(i, i + width).map(async (unit) => { + const output = join(buildDir, unit.member.replace(/\.cpp$/, ".o")); + await execFileAsync(cxx[0]!, [ + ...cxx.slice(1), + ...unit.flags, + ...(unit.define === undefined ? [] : [`-D${unit.define}`]), + "-g0", + "-c", + unit.path, + "-o", + output, + ]); + return output; + }), + ); + } + await execFileAsync("ar", [ + "rcs", + join(buildDir, "libicu.a"), + ...units.map((unit) => join(buildDir, unit.member.replace(/\.cpp$/, ".o"))), + ]); + await publishVendorArtifact(join(buildDir, "libicu.a"), archive); + } finally { + await rm(buildDir, { recursive: true, force: true }); + } + return archive; + } + return { vendorEngineDir, vendorTlsDir, + ensureIcuSources, + icuArchivePath, + ensureIcuArchive, vendorBrotliDir, brotliArchivePath, ensureBrotliArchive, From fc708251c3f3696452283300442e416b08705998 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:15 -0700 Subject: [PATCH 017/193] Ship ICU in the linux-x64-gnu runtime pack with its data beside binaries The pack builds the ICU archive with the same Zig C++ recipe, adds scr_intl.c as an optional unit under the intl predicate, and publishes the archive under the id "icu". Its data file (icudt78l.dat) is not a link input: the pack lists it in a new manifest "data" array, and every Intl build copies it next to the executable it links (verifyArtifact checked, atomic copy, size-checked before reuse). A pack that omits scr_intl.c refuses an Intl program with SC3002 instead of linking undefined scr_intl_ references. The pack fetches both ICU inputs from the pinned release assets: the source tarball (verified, cached, unpacked by the system tar) for the archive, and the data zip (sha256 and size pinned twice) for the data, unpacked by a small stored/deflate reader. Nothing is embedded in a binary. The runtime fingerprint keeps covering the vendored trees, the pinned ICU version keys the archive path, and the pack matrix gains the icu archive with the intl predicate. The isolated native host (native/compiler.ts) links outside the in-process linker and writes its executables synchronously, so it installs the same data artifacts itself (installNativeRuntimeData); the stored pack selection gains a schema version so a selection cached before the data list existed is discarded rather than trusted. Nothing selects a data artifact for a program without the intl feature. The sanitized lane links the pack's ICU archive as shipped. ICU is C++ and compiles with Zig's C++ driver for the packs' glibc floor, so it cannot ride the sanitizer lane's per-TU C rebuild, and instrumenting ~600 C++ TUs would add a multi-minute build to the first Intl program in every sanitized lane. scr_intl.c itself still compiles instrumented with every other runtime unit, and AddressSanitizer's interceptors cover ICU's allocations. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + packages/compiler/src/backend/linker.ts | 41 +++- .../src/backend/native-link-info-core.ts | 4 + .../compiler/src/backend/native-link-info.ts | 1 + .../compiler/src/backend/runtime-pack-core.ts | 25 +- .../src/backend/runtime-pack-native.ts | 50 +++- packages/compiler/src/backend/runtime-pack.ts | 15 +- .../src/executable/executable-cache.ts | 2 + packages/compiler/src/index.ts | 33 ++- packages/compiler/src/native/compiler.ts | 6 + .../compiler/src/native/sanitizer.test.ts | 9 +- packages/compiler/src/native/sanitizer.ts | 15 ++ .../runtime-linux-x64-gnu/scripts/build.mjs | 5 + .../runtime-pack-matrix.mjs | 6 + .../runtime-pack-common/scripts/build.mjs | 202 +++++++++++++++- .../runtime-pack-common/scripts/icu-data.mjs | 221 ++++++++++++++++++ .../runtime-pack-common/scripts/verify.mjs | 1 + 17 files changed, 622 insertions(+), 15 deletions(-) create mode 100644 packages/runtime-pack-common/scripts/icu-data.mjs diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..cad5d947c 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -78,5 +78,6 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ x509: features.x509, webcrypto: features.webcrypto, perfLoop: features.perfLoop, + ...(features.icu ? { intl: true } : {}), }; } diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index ab76597ed..8d4976403 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -7,7 +7,7 @@ import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, realpathSync, statSync } from "node:fs"; -import { mkdtemp, readFile, rename, rm, stat } from "node:fs/promises"; +import { copyFile, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises"; import { delimiter } from "node:path"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -21,7 +21,11 @@ import { toolchainEnvironmentFingerprint, } from "./toolchain-environment.js"; import { type NativeArtifactDependency } from "./native/contracts.js"; -import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js"; +import { + RuntimePackError, + stageRuntimePackArtifacts, + type RuntimePackSelection, +} from "./runtime-pack.js"; import type { NativeLinkPlan } from "./link-plan.js"; import { inputsWithoutLld, lldLinkerArgs } from "./lld.js"; import { hugeTextLinkerArgs, rewriteHugeTextExecutable } from "./huge-text.js"; @@ -225,6 +229,38 @@ async function objectLinkerDependencyPaths( .sort(); } +/** Install the selected runtime pack's RUN-time data files (ICU's + * icudt78l.dat) beside the executable that links them. The compiler's link + * step is the only moment the executable's directory is known, and a data + * file next to the binary is what the runtime looks for first. + * + * A file already present at the pinned size is left alone: the pack ships + * one data set per file name, and re-copying 31.6 MiB on every build would + * be pure cost. The copy is atomic (private temporary, then rename) so two + * concurrent builds of the same program never observe a partial file. */ +export async function installRuntimeDataFiles( + selection: RuntimePackSelection, + outputPath: string, +): Promise { + if (selection.dataFiles.length === 0) return; + const outputDir = dirname(resolve(outputPath)); + for (const source of selection.dataFiles) { + const destination = join(outputDir, basename(source)); + const sourceSize = (await stat(source)).size; + const existing = await stat(destination).catch(() => null); + if (existing?.isFile() && existing.size === sourceSize) continue; + const temporary = join( + outputDir, + `.${basename(destination)}.scriptc-${process.pid}-${randomUUID()}`, + ); + await copyFile(source, temporary); + await rename(temporary, destination).catch(async () => { + await rm(destination, { force: true }); + await rename(temporary, destination); + }); + } +} + export async function linkNativeExecutable( plan: NativeLinkPlan, options: { @@ -314,6 +350,7 @@ export async function linkNativeExecutable( await rm(plan.outputPath, { force: true }); await rename(privateOut, plan.outputPath); }); + await installRuntimeDataFiles(plan.runtimePack, plan.outputPath); if (plan.target.platform === "darwin" && !plan.darwinDebugSymbols) { await rm(`${plan.outputPath}.dSYM`, { recursive: true, force: true }); } diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..02eb70817 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -140,6 +140,10 @@ export interface NativeLinkFeatures { x509: boolean; /** perf_hooks event-loop metrics: scr_perf_loop.c. */ perfLoop: boolean; + /** Intl over ICU (scr_intl.c plus the ICU archive and its data + * file): the `icu.*` libCalls of the first slice. Absent (false) for + * programs without them, so their link features stay unchanged. */ + intl?: boolean; tlsCa: boolean; /** crypto.subtle importKey/sign/verify/digest and CryptoKey values: * scr_webcrypto.c. */ diff --git a/packages/compiler/src/backend/native-link-info.ts b/packages/compiler/src/backend/native-link-info.ts index a4e951055..247918e7b 100644 --- a/packages/compiler/src/backend/native-link-info.ts +++ b/packages/compiler/src/backend/native-link-info.ts @@ -34,6 +34,7 @@ export async function createNativeLinkInfo(options: { features: pack.features, runtime: pack.selectedRuntimeArtifacts, archives: pack.selectedArchiveArtifacts, + data: pack.selectedDataArtifacts, systemLibraries: pack.systemLibraries, }, }); diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 2ce4f21fe..a0f6b289b 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -34,7 +34,7 @@ interface RuntimePackUnit { } interface RuntimePackArchive extends RuntimePackArtifact { - id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls"; + id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls" | "icu"; predicate: RuntimePredicate; } @@ -74,6 +74,11 @@ export interface RuntimePackManifest { }; flavors: Partial>; archives: RuntimePackArchive[]; + /** Files the linked executable needs at RUN time rather than at link time, + * installed beside it on every build. ICU's data file (icudt78l.dat, 31.6 + * MiB) is the only one; Intl programs copy it, every other program links + * neither ICU nor its data. */ + data?: RuntimePackArtifact[]; system_libraries: { name: string; predicate: RuntimePredicate }[]; licenses: { path: string; license: string }[]; } @@ -245,6 +250,8 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest { validArtifact(raw) && typeof archive?.id === "string" && validPredicate(archive.predicate) ); }) || + (manifest.data !== undefined && + (!Array.isArray(manifest.data) || !manifest.data.every(validArtifact))) || !Array.isArray(manifest.system_libraries) || !manifest.system_libraries.every((raw) => { const library = object(raw); @@ -395,6 +402,8 @@ export interface RuntimePackArtifacts { /** Selected unit variants; each may carry its bitcode artifact. */ runtime: (RuntimePackArtifact & { bitcode?: RuntimePackArtifact })[]; archives: RuntimePackArtifact[]; + /** Files to install beside the executable (ICU's data file for Intl). */ + data: RuntimePackArtifact[]; systemLibraries: string[]; } @@ -447,12 +456,26 @@ export function selectRuntimePackArtifacts( `runtime pack has no ${key} flavor; reinstall the matching runtime package`, "invalid", ); + // Intl needs the ICU objects only the host-flavored packs carry (ICU is + // C++, so a pack builds it with Zig's C++ driver for its own glibc target); + // say so instead of linking a program whose scr_intl.c references would go + // undefined. + if ( + features.intl === true && + !selectedFlavor.runtime_units.some((unit) => unit.source === "scr_intl.c") + ) + throw new RuntimePackError( + `the ${manifest.target.name} runtime pack has no ICU objects, which Intl needs; reinstall a runtime package that supports Intl`, + "unsupported", + ); return { features, runtime: selectedRuntimeUnits(selectedFlavor, features).map(({ variant }) => variant), archives: manifest.archives.filter((archive) => evaluateRuntimePredicate(archive.predicate, features), ), + // ICU's data file is 31.6 MiB: only a program that links ICU installs it. + data: features.intl === true ? (manifest.data ?? []) : [], systemLibraries: manifest.system_libraries .filter((entry) => evaluateRuntimePredicate(entry.predicate, features)) .map((entry) => entry.name), diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index 0fff34455..4db429afd 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -4,20 +4,23 @@ * artifacts it verified are kept between builds: an artifact whose file * identity is unchanged since it was hashed is not hashed again. */ import type { NativeOptimization } from "./optimization.js"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { closeSync, constants, + copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, + renameSync, + rmSync, statSync, writeSync, type Stats, } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import type { NativeLinkFeatures } from "./native-link-info.js"; import type { NativeTargetSpec } from "./targets.js"; import { @@ -94,7 +97,9 @@ function storeKey(text: string): string { /** A stored selection: what selecting from the pack's files, with these * identities, returned. */ interface StoredSelection { - schema: 1; + /** 2 adds the selection's data artifacts: a stored selection from before + * them would make an Intl build stage none of them, so it is discarded. */ + schema: 2; licenses: RuntimePackManifest["licenses"]; selected: RuntimePackArtifacts; } @@ -172,7 +177,7 @@ export function selectNativeRuntimePack( sameIdentity(fileIdentity(packagePath), identities[0]!) && sameIdentity(fileIdentity(manifestPath), identities[1]!) ) { - const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected }; + const entry: StoredSelection = { schema: 2, licenses: manifest.licenses, selected }; store.write(key, JSON.stringify(entry)); } } catch { @@ -201,13 +206,14 @@ function readStoredSelection(text: string | null): StoredSelection | null { if (value === null || typeof value !== "object") return null; const entry = value as Partial; if ( - entry.schema !== 1 || + entry.schema !== 2 || !Array.isArray(entry.licenses) || entry.selected === undefined || entry.selected === null || typeof entry.selected !== "object" || !Array.isArray(entry.selected.runtime) || !Array.isArray(entry.selected.archives) || + !Array.isArray(entry.selected.data) || !Array.isArray(entry.selected.systemLibraries) ) return null; @@ -383,3 +389,37 @@ export function stageNativeRuntimeSelection( } return { runtimeObjects, archives, systemLibraries: selected.systemLibraries }; } + +/** Install the selected RUN-time data artifacts (ICU's icudt78l.dat) beside an + * executable this host linked. The in-process linker does the same from its + * own selection (backend/linker.ts); this host writes executables + * synchronously and has no link plan, so it installs them here. + * + * A file already present at the pinned size is left alone (31.6 MiB of pure + * cost otherwise), and the copy is atomic — a private temporary, then a + * rename — so two concurrent builds of one program never read a partial + * file. */ +export function installNativeRuntimeData( + selection: NativeRuntimeSelection, + outputPath: string, +): void { + const data = selection.selected.data ?? []; + if (data.length === 0) return; + const outputDir = dirname(resolve(outputPath)); + for (const artifact of data) { + const destination = join(outputDir, basename(artifact.path)); + const existing = statSync(destination, { throwIfNoEntry: false }); + if (existing?.isFile() === true && existing.size === artifact.size) continue; + const temporary = join( + outputDir, + `.${basename(destination)}.scriptc-${process.pid}-${randomUUID()}`, + ); + copyFileSync(join(selection.root, artifact.path), temporary); + try { + renameSync(temporary, destination); + } catch { + rmSync(destination, { force: true }); + renameSync(temporary, destination); + } + } +} diff --git a/packages/compiler/src/backend/runtime-pack.ts b/packages/compiler/src/backend/runtime-pack.ts index 2bfe71756..ef452f58d 100644 --- a/packages/compiler/src/backend/runtime-pack.ts +++ b/packages/compiler/src/backend/runtime-pack.ts @@ -40,12 +40,15 @@ export interface RuntimePackSelection { features: RuntimeFeatureSet; runtimeObjects: string[]; archives: string[]; + /** Files the executable loads at run time, installed beside it. */ + dataFiles: string[]; systemLibraries: string[]; dependencyPaths: string[]; /** Exact installed inputs observed while the selected artifacts were verified. */ sourceDependencies: NativeArtifactDependency[]; selectedRuntimeArtifacts: RuntimePackArtifact[]; selectedArchiveArtifacts: RuntimePackArtifact[]; + selectedDataArtifacts: RuntimePackArtifact[]; } async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise { @@ -209,8 +212,9 @@ export async function loadRuntimePack(options: { const features = selected.features; const selectedVariants = selected.runtime; const selectedArchives = selected.archives; - const selectedArtifactPaths = [...selectedVariants, ...selectedArchives].map((artifact) => - join(root, artifact.path), + const selectedData = selected.data; + const selectedArtifactPaths = [...selectedVariants, ...selectedArchives, ...selectedData].map( + (artifact) => join(root, artifact.path), ); let artifactDependencies: NativeArtifactDependency[]; try { @@ -218,9 +222,10 @@ export async function loadRuntimePack(options: { } catch { throw new RuntimePackError("runtime pack artifact set changed during selection", "invalid"); } - const [runtimeObjects, archives] = await Promise.all([ + const [runtimeObjects, archives, dataFiles] = await Promise.all([ Promise.all(selectedVariants.map((artifact) => verifyArtifact(root, artifact))), Promise.all(selectedArchives.map((artifact) => verifyArtifact(root, artifact))), + Promise.all(selectedData.map((artifact) => verifyArtifact(root, artifact))), ]); await Promise.all(manifest.licenses.map((license) => readFile(join(root, license.path)))).catch( () => { @@ -242,10 +247,12 @@ export async function loadRuntimePack(options: { features, runtimeObjects, archives, + dataFiles, systemLibraries: selected.systemLibraries, - dependencyPaths: [packagePath, manifestPath, ...runtimeObjects, ...archives], + dependencyPaths: [packagePath, manifestPath, ...runtimeObjects, ...archives, ...dataFiles], sourceDependencies, selectedRuntimeArtifacts: selectedVariants, selectedArchiveArtifacts: selectedArchives, + selectedDataArtifacts: selectedData, }; } diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..f6e5e030f 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -121,6 +121,8 @@ export interface EarlyExecutableNativeFeatures { perfLoop: boolean; tlsCa: boolean; webcrypto: boolean; + /** Intl over ICU (scr_intl.c, the ICU archive and its data file). */ + intl?: boolean; } interface EarlyExecutableCacheStamp { diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..0eaa550b9 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -107,7 +107,7 @@ import { } from "./backend/targets.js"; import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js"; import { createNativeLinkInfo } from "./backend/native-link-info.js"; -import { RuntimePackError } from "./backend/runtime-pack.js"; +import { RuntimePackError, loadRuntimePack } from "./backend/runtime-pack.js"; import { createNativeLinkPlan } from "./backend/link-plan.js"; import { nativeProfileKey, @@ -121,6 +121,7 @@ import { } from "./backend/native-profile.js"; import { executableLinkerEnvironmentFingerprint, + installRuntimeDataFiles, linkNativeExecutable, platformLinkerSupportsPersistentCache, resolvePlatformLinker, @@ -615,6 +616,30 @@ function executableNativeFeatures( }; } +/** Install the run-time data files of the selected runtime pack beside an + * executable this build linked while features.intl is true (ICU's + * icudt78l.dat). Every path that produces an Intl executable calls it: the + * pack link path installs them from its own selection (linker.ts), and the + * C-source path — sanitized builds, and links that do not start from a + * program object — compiles the runtime itself and would otherwise leave a + * binary whose Intl forms throw at run time for want of data. */ +async function installRuntimeDataBesideExecutable( + features: EarlyExecutableNativeFeatures, + outPath: string, +): Promise { + if (features.intl !== true) return; + const target = nativeCodegenTarget(); + if (target === null) return; + await installRuntimeDataFiles( + await loadRuntimePack({ + target, + features, + optimization: features.optimization ?? "release", + }), + outPath, + ); +} + async function compileExecutableNative( features: EarlyExecutableNativeFeatures, llvmPath: string, @@ -705,6 +730,7 @@ async function compileExecutableNative( ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), + ...(features.intl ? { intl: true } : {}), assert: features.assert, inspect: features.inspect, dynInvoke: features.dynInvoke, @@ -768,6 +794,7 @@ async function compileExecutableNative( frameworks: ffi.frameworks, }), }); + await installRuntimeDataBesideExecutable(features, outPath); } interface NativeProgramObject { @@ -1907,6 +1934,10 @@ async function compileTracked( // index so the next identical CLI invocation can avoid this module graph. await publishEarlyExecutableRoute(cacheRoot, executableCacheOptions).catch(() => undefined); if (earlyHit.executableRestored) { + // The executable came back from the cache without a link, so the data + // files its Intl program loads at RUN time (ICU's icudt78l.dat) are not + // beside it yet; a cache hit that links gets them from the linker. + await installRuntimeDataBesideExecutable(earlyHit.native, opts.outPath); await pruneBuildCache(cacheRoot); timing("complete"); return { diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 3b2953f6e..a8b61498b 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -33,6 +33,7 @@ import { } from "../backend/native-tools.js"; import { type NativeRuntimeSelection, + installNativeRuntimeData, selectNativeRuntimePack, runtimePackManifest, stageNativeRuntimeSelection, @@ -727,6 +728,11 @@ export class NativeCompiler { renameSync(stagedOutput, output); executableCache?.recordOutput(output); } + // The ICU data file is a RUN-time input: it goes beside the executable, + // on the cache-hit path too (the restored binary loads it all the + // same). Nothing else selects a data artifact, so the pack is asked + // only for Intl programs. + if (features.intl === true) installNativeRuntimeData(selectPack(), output); if (target.platform === "darwin" && (optimization !== "dev" || options.strip)) rmSync(output + ".dSYM", { recursive: true, force: true }); return { diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index b1d03b52d..afea867b9 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -150,6 +150,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( selected: { runtime: [], archives: [artifact], + data: [], systemLibraries: [], features, }, @@ -243,7 +244,13 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( packageText: "", manifestText: "", flavor: "dev", - selected: { runtime: [variants[0]!], archives: [], systemLibraries: [], features }, + selected: { + runtime: [variants[0]!], + archives: [], + data: [], + systemLibraries: [], + features, + }, }, directory, "executable", diff --git a/packages/compiler/src/native/sanitizer.ts b/packages/compiler/src/native/sanitizer.ts index 81667b28b..d00e2fbd7 100644 --- a/packages/compiler/src/native/sanitizer.ts +++ b/packages/compiler/src/native/sanitizer.ts @@ -163,6 +163,21 @@ export function buildSanitizedRuntime( const name = basename(archive.path) .replace(/^libscriptc-/, "") .replace(/\.a$/, ""); + // ICU is the one vendored prerequisite this loop cannot rebuild: it is + // C++, and its archive compiles with Zig's C++ driver (ensureIcuArchive + // in backend/vendor-archives.ts) so it binds the packs' glibc floor — + // not the sanitizer lane's C driver. It is also by far the largest + // (~600 TUs), so instrumenting it would add a multi-minute C++ build to + // the first Intl program in every sanitized lane. The pack's archive is + // linked as shipped instead: this repository's ICU code (scr_intl.c) + // still compiles instrumented with every other runtime unit, and + // AddressSanitizer's interceptors cover ICU's allocations. The pack is + // installed beside the sources, not an evictable cache, so the path can + // be linked directly. + if (name === "icu") { + archives.push(join(selection.root, archive.path)); + continue; + } let sources: readonly string[]; let directory: string; let args: string[]; diff --git a/packages/runtime-linux-x64-gnu/scripts/build.mjs b/packages/runtime-linux-x64-gnu/scripts/build.mjs index 53df5f7e7..04befbf29 100644 --- a/packages/runtime-linux-x64-gnu/scripts/build.mjs +++ b/packages/runtime-linux-x64-gnu/scripts/build.mjs @@ -24,6 +24,11 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], + // Intl over ICU. The pack ships the C++ ICU archive and its data file + // (artifacts/icu/icudt78l.dat); the cross and non-Linux packs omit both, + // and the compiler refuses Intl there (SC3002). This slice is + // linux-x64-gnu, the target the archive's Zig C++ recipe builds for. + icu: true, // scr_unwind_raise: the static libgcc_eh, which loads no shared library. systemLibraries: [ { name: "m", predicate: true }, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..8853b90c6 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -99,6 +99,11 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], ["scr_brotli.c", all("zlib", "brotli")], + // Intl over ICU (scr_intl.c): the default-locale number/date formats and + // word segmentation, over the packed ICU archive and its data file. + // Every engine-free Intl form (the en-US fast paths, default grapheme + // clusters) keeps its own unit and links none of this. + ["scr_intl.c", "intl"], ["scr_events.c", "events"], ["scr_readline.c", "events"], ["scr_readline_tty.c", all("events", "regex")], @@ -361,6 +366,7 @@ export function createRuntimePackMatrix({ { id: "libregexp", predicate: "regex" }, { id: "zlib", predicate: "zlibEffective" }, { id: "brotli", predicate: all("zlib", "brotli") }, + { id: "icu", predicate: "intl" }, { id: "mbedtls", predicate: any("tlsEffective", "cipher", "ecdh", "dh", "pki") }, ]; const matrix = withLibraryRuntimeFlavors( diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 97e03b189..c4f81d4a0 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -20,8 +20,20 @@ import { installRuntimePack, withBuildLock } from "./build-state.mjs"; import { shareIdenticalArtifacts } from "./share-artifacts.mjs"; import { parallelMap } from "./parallel-map.mjs"; import { zlibInputs } from "./zlib-inputs.mjs"; +import { ICU_DATA_FILE, ensureIcuData, ensureIcuSources, icuSourceList } from "./icu-data.mjs"; const run = promisify(execFile); + +/** Zig's library directory, which carries the libc++/libc++abi sources the ICU + * archive merges: `ZIG_LIB_DIR` when the caller pins one, else the pinned + * toolchain's own answer. */ +async function zigLibDir() { + if (process.env.ZIG_LIB_DIR) return process.env.ZIG_LIB_DIR; + const { stdout } = await run("zig", ["env"]); + const match = /\.lib_dir\s*=\s*"([^"]+)"/.exec(stdout); + if (match === null) throw new Error("could not read zig's lib directory from `zig env`"); + return match[1]; +} const packageRoot = process.env.SCRIPTC_RUNTIME_PACK_ROOT; const configText = process.env.SCRIPTC_RUNTIME_PACK_CONFIG; if (!packageRoot || !configText) @@ -33,7 +45,18 @@ const runtimeSrc = join(runtimeRoot, "src"); const vendorRoot = join(runtimeRoot, "vendor"); const outputRoot = join(packageRoot, "artifacts"); const manifestPath = join(packageRoot, "runtime-pack.json"); -const matrix = createRuntimePackMatrix(config); +// ICU (and so every ICU-answered Intl form) ships only in the packs that opt +// in with `icu: true` — linux-x64-gnu in this slice, the one pack whose build +// runs Zig's C++ driver for a glibc target. Every other pack omits the unit +// and the archive, and the compiler refuses Intl there (SC3002). +const matrix = createRuntimePackMatrix({ + ...config, + omitOptionalSources: [ + ...(config.omitOptionalSources ?? []), + ...(config.icu === true ? [] : ["scr_intl.c"]), + ], + omitArchives: [...(config.omitArchives ?? []), ...(config.icu === true ? [] : ["icu"])], +}); async function build() { const buildRoot = join( @@ -90,6 +113,10 @@ async function build() { const zlib = join(vendorRoot, "zlib"); const mbedtls = join(vendorRoot, "mbedtls"); const brotli = join(vendorRoot, "brotli", "c"); + // ICU is fetched from its pinned release, not vendored (see icu-inputs.mjs), + // and only a pack that ships it pays for the download. + const icuSourceRoot = async () => + ensureIcuSources(join(repoRoot, "node_modules", ".cache", "scriptc-icu-sources")); const qjsSources = ["dtoa.c", "libregexp.c", "libunicode.c", "quickjs.c"]; const lreSources = ["libregexp.c", "libunicode.c"]; const zlibBuild = zlibInputs(config.target.llvm_triple); @@ -193,6 +220,141 @@ async function build() { size: (await stat(output)).size, }; }; + // ICU (Intl over ICU4C): the C++ units ICU's own source lists name, plus the + // C++ standard library they need, merged INTO the archive so the final link + // stays a plain C link with the pack's own `-lm`. Four things distinguish it + // from every other vendored archive: + // - It is C++, so this pack's C driver cannot compile it: the TUs (and the + // standard library they use) build with Zig's C++ driver for the PACK'S + // OWN target. That target is the point. In C++ mode, glibc 2.38+ headers + // redirect strtol/strtoul to the C23 __isoc23_* names, and a host + // libstdc++ binds those names too, so a host-compiled C++ archive would + // raise the glibc floor above the pack's. Zig's bundled libc++ and + // libc++abi compile from the toolchain's own sources for the target and + // bind the target's names only; the pack's forbidden-artifact check + // proves it. + // - ICU requires RTTI (typeid and dynamic_cast in its source), so -fno-rtti + // is not available; -fno-exceptions is, and ICU, libc++, and libc++abi + // all build so. That also means libc++abi's Itanium personality routine + // (cxa_personality.cpp) is out: it is the one unit that needs exceptions, + // and nothing can throw into it. + // - Symbol renaming stays ON (the `_78` suffixes), which is what lets the + // zero-cost gate prove that non-Intl binaries link no ICU symbol. + // - Only a pack that opts in with `icu: true` ships the unit and this + // archive; the compiler refuses Intl in every other pack (SC3002). + // packages/compiler/src/backend/vendor-archives.ts (ensureIcuArchive) + // builds the same archive on the source-build path. + const icuArchive = async () => { + process.stdout.write(`building ${packageManifest.name} icu archive\n`); + // The tarball extracts to `icu/source/...` with ICU's LICENSE beside it. + const icu = join(await icuSourceRoot(), "source"); + const root = join(stagedOutputRoot, "vendor", "icu"); + const objectRoot = join(root, "objects"); + const libDir = await zigLibDir(); + const cxx = ["zig", "c++", ...config.targetArgs]; + const optimize = ["-Os"]; + const commonFlags = [ + "-std=c++17", + ...optimize, + "-fno-exceptions", + "-ffunction-sections", + "-fdata-sections", + ]; + const icuFlags = [ + ...commonFlags, + "-DU_STATIC_IMPLEMENTATION", + "-DU_ATTRIBUTE_DEPRECATED=", + "-DNDEBUG", + "-Wno-deprecated-declarations", + "-I", + join(icu, "common"), + "-I", + join(icu, "i18n"), + ]; + // libc++ 20 sources need C++20 (`constinit`, ranges) and libc++'s + // charconv reads a header from `libcxx/libc`. + const cxxLibraryFlags = [ + "-std=c++23", + ...optimize, + "-fno-exceptions", + "-ffunction-sections", + "-fdata-sections", + "-I", + join(libDir, "libcxx", "include"), + "-I", + join(libDir, "libcxx", "src"), + "-I", + join(libDir, "libcxx", "libc"), + "-I", + join(libDir, "libcxxabi", "include"), + "-D_LIBCPP_BUILDING_LIBRARY", + "-D_LIBCXXABI_BUILDING_LIBRARY", + "-DLIBCXX_BUILDING_LIBCXXABI", + "-Wno-unknown-warning-option", + ]; + const cxxLibraryUnits = async (subdirectory, memberPrefix, skip) => { + const sources = (await readdir(join(libDir, subdirectory, "src"))) + .filter((name) => name.endsWith(".cpp") && !skip.has(name)) + .sort(); + return sources.map((source) => ({ + path: join(libDir, subdirectory, "src", source), + member: `${memberPrefix}${source}`, + flags: cxxLibraryFlags, + })); + }; + const units = [ + ...icuSourceList(await readFile(join(icu, "common", "sources.txt"), "utf8")).map( + (source) => ({ + path: join(icu, "common", source), + member: `common-${source}`, + flags: icuFlags, + define: "U_COMMON_IMPLEMENTATION", + }), + ), + { + path: join(icu, "stubdata", "stubdata.cpp"), + member: "stubdata-stubdata.cpp", + flags: icuFlags, + define: "U_COMMON_IMPLEMENTATION", + }, + ...icuSourceList(await readFile(join(icu, "i18n", "sources.txt"), "utf8")).map((source) => ({ + path: join(icu, "i18n", source), + member: `i18n-${source}`, + flags: icuFlags, + define: "U_I18N_IMPLEMENTATION", + })), + ...(await cxxLibraryUnits("libcxx", "cxx-libcxx-", new Set())), + ...(await cxxLibraryUnits("libcxxabi", "cxx-libcxxabi-", new Set(["cxa_personality.cpp"]))), + ]; + // Member order is fixed by the source lists (parallel() preserves it), + // so the archive's bytes depend only on the inputs. + const objects = await parallel(units, async (unit) => { + const output = join(objectRoot, unit.member.replace(/\.cpp$/, ".o")); + await mkdir(dirname(output), { recursive: true }); + await run(cxx[0], [ + ...cxx.slice(1), + ...sourcePathFlags, + ...unit.flags, + ...(unit.define === undefined ? [] : [`-D${unit.define}`]), + "-g0", + "-c", + unit.path, + "-o", + output, + ]); + return output; + }); + const output = join(root, "libscriptc-icu.a"); + await createDeterministicArchive(archiver, output, objects, archiverArgs); + await rm(objectRoot, { recursive: true, force: true }); + return { + id: "icu", + path: artifactPath(output), + sha256: await sha256(output), + size: (await stat(output)).size, + }; + }; + await rm(buildRoot, { recursive: true, force: true }); try { await mkdir(stagedOutputRoot, { recursive: true }); @@ -270,6 +432,18 @@ async function build() { ? ["-I", zlib] : []), ...(unit.source === "scr_brotli.c" ? ["-I", join(brotli, "include")] : []), + // scr_intl.c includes ICU's C API headers straight from the + // fetched release: the core ones in source/common/unicode/*.h, + // and the format ones (udat.h, unumberformatter.h) in + // source/i18n/unicode/*.h. + ...(unit.source === "scr_intl.c" + ? [ + "-I", + join(await icuSourceRoot(), "source", "common"), + "-I", + join(await icuSourceRoot(), "source", "i18n"), + ] + : []), ]; // Worker variants link only into executables, whose own TLS block // holds every runtime thread-local: on ELF the initial-exec model @@ -476,6 +650,7 @@ async function build() { ]), ] : []), + ...(requestedArchives.has("icu") ? [await icuArchive()] : []), ...(requestedArchives.has("mbedtls") ? [ await archive("mbedtls", mbedtlsSources, join(mbedtls, "library"), [ @@ -490,6 +665,25 @@ async function build() { ] : []), ]; + // ICU's data is a RUN-time input, not a link input: the executable loads + // icudt78l.dat from beside itself (or ICU_DATA). `data` tells the + // compiler where the pack keeps it, so every Intl build installs it + // beside the binary it links. + const data = []; + if (requestedArchives.has("icu")) { + process.stdout.write(`building ${packageManifest.name} icu data\n`); + const source = await ensureIcuData( + join(repoRoot, "node_modules", ".cache", "scriptc-icu-data"), + ); + const destination = join(stagedOutputRoot, "icu", ICU_DATA_FILE); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + data.push({ + path: artifactPath(destination), + sha256: await sha256(destination), + size: (await stat(destination)).size, + }); + } await assertArtifactsExcludeStrings( stagedOutputRoot, runtimeForbiddenStrings(config.forbiddenArtifactStrings), @@ -513,6 +707,11 @@ async function build() { [join(zlib, "LICENSE.chromium"), "artifacts/licenses/zlib-chromium.txt", "BSD-3-Clause"], [join(vendorRoot, "brotli", "LICENSE"), "artifacts/licenses/brotli.txt", "MIT"], [join(mbedtls, "LICENSE"), "artifacts/licenses/mbedtls.txt", "Apache-2.0"], + // ICU's terms cover both the sources and the data file it ships; only a + // pack that ships them carries its license. + ...(requestedArchives.has("icu") + ? [[join(await icuSourceRoot(), "LICENSE"), "artifacts/licenses/icu.txt", "Unicode-3.0"]] + : []), ]; licensed.push([ join(vendorRoot, "unicode", "LICENSE"), @@ -558,6 +757,7 @@ async function build() { ...entry, predicate: archiveSpecs.get(entry.id).predicate, })), + ...(data.length === 0 ? {} : { data }), system_libraries: matrix.system_libraries, licenses: licensed.map(([, path, license]) => ({ path, license })), }; diff --git a/packages/runtime-pack-common/scripts/icu-data.mjs b/packages/runtime-pack-common/scripts/icu-data.mjs new file mode 100644 index 000000000..2d5e14e38 --- /dev/null +++ b/packages/runtime-pack-common/scripts/icu-data.mjs @@ -0,0 +1,221 @@ +/* ICU's sources and data file, pinned and fetched for the runtime pack. + * + * Intl answers from ICU, so the pack builds ICU's C library from the release + * source tarball and ships its data as a FILE the executable loads at run + * time (31.6 MiB of CLDR patterns, locale metadata, break rules and + * time-zone shapes) — never embedded in the binary. Neither is vendored: + * both come from unicode-org/icu's official GitHub release for the pinned + * version, each downloaded once into a cache and reused after that, and each + * verified against a pinned sha256 on every use (the source by the tarball's, + * the data twice: the zip and the extracted `.dat`). A bad download fails + * here with both hashes, never later in a build. Offline builds work from a + * pre-populated cache. + * + * The pack build drops `icudt78l.dat` into artifacts/icu/ and lists it in + * runtime-pack.json's `data`, so the compiler can install it beside every + * executable that links ICU (packages/compiler/src/backend/linker.ts). The + * data zip is unpacked by the small reader below rather than a dependency: + * it holds three stored/deflated members and nothing else. The source + * tarball is unpacked by the system `tar`, as the libnode build does. + */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { inflateRawSync, crc32 } from "node:zlib"; +import { + ICU_DATA_FILE, + ICU_DATA_SHA256, + ICU_DATA_SIZE, + ICU_DATA_URL, + ICU_DATA_ZIP_SHA256, + ICU_DATA_ZIP_SIZE, + ICU_SOURCE_SHA256, + ICU_SOURCE_SIZE, + ICU_SOURCE_URL, + ICU_VERSION, +} from "./icu-inputs.mjs"; + +const run = promisify(execFile); + +/** Node v24.22.0 bundles ICU 78.3 (`process.versions.icu`), whose data + * release is this file. */ +export { ICU_DATA_FILE }; +export const ICU_DATA_VERSION = ICU_VERSION; + +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +/** The ICU source tree, fetched once into `cacheDir` and reused after that. + * Returns the release root (`icu/`, holding `source/` and LICENSE); the + * cached tarball is re-verified on every call, so a corrupted cache is + * refetched rather than trusted. */ +export async function ensureIcuSources(cacheDir) { + const tree = join(cacheDir, `icu4c-${ICU_VERSION}-source`); + const marker = () => join(tree, "source", "common", "sources.txt"); + if (await present(marker())) return tree; + const tarball = join(cacheDir, `icu4c-${ICU_VERSION}-sources.tgz`); + let archive; + try { + archive = await readFile(tarball); + if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256) + throw new Error("cached ICU source tarball does not match its pin"); + } catch { + let response; + try { + response = await fetch(ICU_SOURCE_URL); + } catch (cause) { + throw new Error( + `could not download ICU's sources (${ICU_SOURCE_URL}); ` + + `populate ${tarball} or allow network access`, + { cause }, + ); + } + if (!response.ok) + throw new Error( + `could not download ICU's sources (${ICU_SOURCE_URL}): ${response.status} ${response.statusText}`, + ); + archive = Buffer.from(await response.arrayBuffer()); + if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256) + throw new Error( + `ICU source tarball from ${ICU_SOURCE_URL} is ${archive.length} bytes with sha256 ` + + `${sha256(archive)}, expected ${ICU_SOURCE_SIZE} bytes with sha256 ${ICU_SOURCE_SHA256}`, + ); + await mkdir(cacheDir, { recursive: true }); + await writeFile(tarball, archive); + } + await mkdir(cacheDir, { recursive: true }); + const stage = await mkdtemp(join(cacheDir, ".icu-sources-")); + try { + await run("tar", ["-xzf", tarball, "-C", stage]); + if (!(await present(join(stage, "icu", "source", "common", "sources.txt")))) + throw new Error(`ICU source tarball ${ICU_SOURCE_URL} does not hold icu/source`); + await rm(tree, { recursive: true, force: true }); + await rename(join(stage, "icu"), tree); + } finally { + await rm(stage, { recursive: true, force: true }); + } + return tree; +} + +async function present(path) { + try { + return (await stat(path)).size > 0; + } catch { + return false; + } +} + +/** The translation units one of ICU's own source lists names, in its order. + * The lists (`source/common/sources.txt`, `source/i18n/sources.txt`) come + * verbatim in the fetched tarball, so a build compiles exactly the units + * ICU's own autoconf build does. packages/compiler/src/backend/vendor-inputs.ts + * keeps the same parser for the source-build path. */ +export function icuSourceList(text) { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} + +/** The `.dat` member of a zip archive whose entry names we trust only as far + * as the pinned sha256 of the whole archive. Supports the two methods the + * ICU data zip uses (0 = stored, 8 = deflate) and verifies each member's + * CRC-32, so a truncated or altered download fails here, not at run time. */ +export function unzipMember(zip, name) { + // End of central directory: 22 bytes plus a comment of up to 64 KiB. + let eocd = -1; + for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 0xffff); i--) { + if (zip.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error("ICU data zip has no end-of-central-directory record"); + const entries = zip.readUInt16LE(eocd + 10); + let cursor = zip.readUInt32LE(eocd + 16); + for (let i = 0; i < entries; i++) { + if (zip.readUInt32LE(cursor) !== 0x02014b50) + throw new Error("ICU data zip has a malformed central-directory entry"); + const method = zip.readUInt16LE(cursor + 10); + const expectedCrc = zip.readUInt32LE(cursor + 16); + const compressedSize = zip.readUInt32LE(cursor + 20); + const size = zip.readUInt32LE(cursor + 24); + const nameLength = zip.readUInt16LE(cursor + 28); + const extraLength = zip.readUInt16LE(cursor + 30); + const commentLength = zip.readUInt16LE(cursor + 32); + const nameStart = cursor + 46; + const entryName = zip.toString("utf8", nameStart, nameStart + nameLength); + const localHeader = zip.readUInt32LE(cursor + 42); + cursor = nameStart + nameLength + extraLength + commentLength; + if (entryName !== name) continue; + if (zip.readUInt32LE(localHeader) !== 0x04034b50) + throw new Error(`ICU data zip member ${name} has a malformed local header`); + const dataStart = + localHeader + 30 + zip.readUInt16LE(localHeader + 26) + zip.readUInt16LE(localHeader + 28); + const payload = zip.subarray(dataStart, dataStart + compressedSize); + if (payload.length !== compressedSize) + throw new Error(`ICU data zip member ${name} is truncated`); + const contents = + method === 0 + ? Buffer.from(payload) + : method === 8 + ? inflateRawSync(payload) + : (() => { + throw new Error(`ICU data zip member ${name} uses unsupported method ${method}`); + })(); + if (contents.length !== size) + throw new Error(`ICU data zip member ${name} is ${contents.length} bytes, expected ${size}`); + if (crc32(contents) !== expectedCrc) + throw new Error(`ICU data zip member ${name} failed its CRC-32 check`); + return contents; + } + throw new Error(`ICU data zip does not contain ${name}`); +} + +/** The pinned data file, downloaded once into `cacheDir` and reused after + * that. Returns the path to the extracted `.dat`, whose bytes are verified + * against the pinned sha256 on every call (the cached zip is checked too, so + * a corrupted cache is refetched rather than trusted). */ +export async function ensureIcuData(cacheDir) { + const zipPath = join(cacheDir, `icu4c-${ICU_DATA_VERSION}-data-bin-l.zip`); + const dataPath = join(cacheDir, `${ICU_DATA_VERSION}`, ICU_DATA_FILE); + if (await validFile(dataPath, ICU_DATA_SHA256)) return dataPath; + let zip; + try { + zip = await readFile(zipPath); + if (zip.length !== ICU_DATA_ZIP_SIZE || sha256(zip) !== ICU_DATA_ZIP_SHA256) + throw new Error("cached ICU data zip does not match its pin"); + } catch { + const response = await fetch(ICU_DATA_URL); + if (!response.ok) + throw new Error( + `could not download ICU's data (${ICU_DATA_URL}): ${response.status} ${response.statusText}`, + ); + zip = Buffer.from(await response.arrayBuffer()); + if (zip.length !== ICU_DATA_ZIP_SIZE || sha256(zip) !== ICU_DATA_ZIP_SHA256) + throw new Error( + `ICU data zip from ${ICU_DATA_URL} is ${zip.length} bytes with sha256 ${sha256(zip)}, ` + + `expected ${ICU_DATA_ZIP_SIZE} bytes with sha256 ${ICU_DATA_ZIP_SHA256}`, + ); + await mkdir(cacheDir, { recursive: true }); + await writeFile(zipPath, zip); + } + const bytes = unzipMember(zip, ICU_DATA_FILE); + if (bytes.length !== ICU_DATA_SIZE || sha256(bytes) !== ICU_DATA_SHA256) + throw new Error(`ICU data file ${ICU_DATA_FILE} does not match its pin`); + await mkdir(join(cacheDir, ICU_DATA_VERSION), { recursive: true }); + const temporary = `${dataPath}.${process.pid}.tmp`; + await writeFile(temporary, bytes); + await rename(temporary, dataPath); + return dataPath; +} + +async function validFile(path, expectedSha256) { + try { + if ((await stat(path)).size !== ICU_DATA_SIZE) return false; + return sha256(await readFile(path)) === expectedSha256; + } catch { + return false; + } +} diff --git a/packages/runtime-pack-common/scripts/verify.mjs b/packages/runtime-pack-common/scripts/verify.mjs index 029de6565..026fd84db 100644 --- a/packages/runtime-pack-common/scripts/verify.mjs +++ b/packages/runtime-pack-common/scripts/verify.mjs @@ -23,6 +23,7 @@ const artifacts = [ ), ), ...manifest.archives, + ...(manifest.data ?? []), ]; for (const artifact of artifacts) { const bytes = await readFile(join(root, artifact.path)); From 77faf0c9e691f3618e7c370136d990810fa4521f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:18 -0700 Subject: [PATCH 018/193] Accept the ICU-answered Intl forms and narrow their refusals The forms the first slice answers now lower: x.toLocaleString() (and an explicit undefined locale) to icu.numFormatDefault, the argument-free Date#toLocaleString/toLocaleTimeString to the ICU date formatters, and Intl.Segmenter({ granularity: "word" }) to the ICU word iterator (a literal language tag is the locale; the empty string is the default). Sentence segmentation keeps its refusing construction in JavaScript dependencies, the grapheme segmenter stays the engine-free fast path, and the code notes the deletion I4 schedules. The refusals that named these forms follow: the four stdlib hints, the Intl identifier hint, the Date method hint and the Segmenter option messages list what lowers and what still does not (locale negotiation, options bags, toLocaleDateString, formatter values). The library mode refuses the icu.* libCalls, whose ambient locale state and data file a library archive cannot carry, and the ambient process.config comment says what a compiled binary can honestly answer. An argument that IS undefined (exactly undefined, or void) means the absent form and lowers; one whose type merely includes undefined may hold a locale or an options bag at run time, and the constructor cannot read either, so it is refused rather than silently treated as absent. Co-Authored-By: Claude Opus 5.5 --- .../content/limitations/builtin-apis/date.mdx | 4 +- .../builtin-apis/intl-segmenter.mdx | 5 +- .../ambient/scriptc-node-fallback.d.ts | 13 +++-- .../compiler/src/backend/llvm/lib-dispatch.ts | 4 ++ .../compiler/src/backend/llvm/lib-shared.ts | 5 ++ .../src/frontend/lowering/builtins/dates.ts | 15 ++++- .../src/frontend/lowering/builtins/process.ts | 14 ++++- .../lowering/expressions/identifiers.ts | 2 +- .../src/frontend/lowering/lower-calls.ts | 58 ++++++++++++++----- .../src/frontend/lowering/lower-classes.ts | 50 +++++++++++----- .../compiler/src/frontend/lowering/lowerer.ts | 8 ++- .../src/frontend/lowering/surfaces.ts | 10 ++-- .../compiler/src/ir/builtin-signatures.ts | 19 ++++++ packages/compiler/src/ir/runtime-features.ts | 7 +++ packages/compiler/src/library/ir-policy.ts | 3 + tests/coverage-fixtures/dynamic-mix.ts | 4 +- tests/coverage-fixtures/tier-demotions.ts | 4 +- tests/diagnostics/date-formatters.ts | 6 +- tests/diagnostics/dynamic-surface.ts | 6 +- tests/diagnostics/intl-segmenter.ts | 6 +- tests/diagnostics/intl.ts | 7 +-- tests/diagnostics/stdlib-fence.ts | 2 +- tests/harness/coverage.test.ts | 9 ++- 23 files changed, 191 insertions(+), 70 deletions(-) diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx index d783984fa..c15efd94e 100644 --- a/docs/content/limitations/builtin-apis/date.mdx +++ b/docs/content/limitations/builtin-apis/date.mdx @@ -1,7 +1,7 @@ -- **Date:** Spread constructor arguments and locale formatters are unsupported. +- **Date:** Spread constructor arguments, `toLocaleDateString()`, and locale or options arguments to the locale formatters are unsupported. Dates are objects with identity: `===` and `==` compare identity, `new Date(date)` makes a new Date, setters change the one Date every reference holds, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in unions with other types, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators. Thrown and rejected Dates keep their identity; `structuredClone` and `postMessage` copy them. - Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile. + Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the argument-free `toLocaleString()` and `toLocaleTimeString()` (the default locale's ICU patterns over the host time zone, as Node prints them), the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile. Unary `+`, `-` and `Number(date)` read the time value; `String(date)`, template literals and `+` with a string read the `toString()` text; `date[Symbol.toPrimitive](hint)` compiles with a literal hint. A Date converted to `unknown` is the same Date object. See [Dates and timezones](#dates-and-timezones) for timezone data. diff --git a/docs/content/limitations/builtin-apis/intl-segmenter.mdx b/docs/content/limitations/builtin-apis/intl-segmenter.mdx index 2682acaf0..f762b6712 100644 --- a/docs/content/limitations/builtin-apis/intl-segmenter.mdx +++ b/docs/content/limitations/builtin-apis/intl-segmenter.mdx @@ -1,3 +1,4 @@ - **Intl.Segmenter:** Unicode grapheme segmentation, iteration, and `containing()` compile, with the default locale or one language tag of the form language[-script][-region] written as a string literal (grapheme clusters do not depend on the locale). - In JavaScript dependencies, word and sentence segmenters construct, and their `segment()` throws an SC2020 error. - Locale negotiation over several locales, word and sentence segmentation, `resolvedOptions()`, and detached methods are unsupported. + Word granularity compiles too, over ICU's UAX #29 word iterator at that locale; its segments carry `isWordLike` exactly as Node.js's do. + In JavaScript dependencies, sentence segmenters construct, and their `segment()` throws an SC2020 error; in TypeScript sources the construction is refused. + Locale negotiation over several locales, sentence segmentation, `resolvedOptions()`, and detached methods are unsupported. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..35406c7b4 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -246,12 +246,13 @@ declare var process: { /* "v" plus versions.node: the compatibility target's release. */ readonly version: string; /* The build-configuration snapshot Node exposes from its gyp config. - * A scriptc binary has no V8 and no gyp, so `variables` answers the - * honest capability record (v8_enable_i18n_support: 0 — no ICU; asan - * mirrors the sanitizer build flag) and `target_defaults` is absent — - * probe code takes its documented fallbacks. Reads of undeclared - * variables go through the index signature (string | number | boolean - * | undefined) and answer undefined. */ + * A scriptc binary has no V8 and no gyp, so `variables` answers what is + * true of a compiled binary: v8_enable_i18n_support is 1 like Node's + * (the read links ICU, which the binary then carries), while the gyp + * variables it cannot answer (icu_path, asan, …) read undefined through + * the index signature (string | number | boolean | undefined), and + * `target_defaults` is absent — probe code takes its documented + * fallbacks. */ readonly config: { readonly variables: { readonly [name: string]: string | number | boolean | undefined }; readonly target_defaults?: { readonly default_configuration: string }; diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 57a09d85d..2038f90b1 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -1184,6 +1184,10 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "str": case "regexp": case "intl": + // ICU-backed Intl (scr_intl.c): ordinary libCalls whose C symbols the + // runtime provides — the same generic emission the engine-free intl + // forms use. + case "icu": case "sym": case "perf": case "number": diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02479a922..27d30f40e 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -67,6 +67,11 @@ export const LIB_FN_SYMS: Record = { "http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw", "intl.segmenterNew": "scr_intl_segmenter_new", "intl.segmenterRefusing": "scr_intl_segmenter_refusing", + "icu.segmenterWord": "scr_intl_segmenter_word_new", + "icu.numFormatDefault": "scr_intl_num_format_default", + "icu.i18nSupport": "scr_intl_i18n_support", + "icu.dateLocaleString": "scr_intl_date_locale_string", + "icu.dateLocaleTimeString": "scr_intl_date_locale_time_string", "weakMap.is": "scr_weak_map_is", "weakSet.is": "scr_weak_set_is", "weakMap.new": "scr_weak_map_new", diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts index 075047606..d6deb771f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/dates.ts +++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts @@ -72,7 +72,7 @@ const DATE_TEXT_FNS: ReadonlyMap = new Map([ ]); export const DATE_METHOD_HINT = - "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering"; + "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering"; /** Date statics and methods on Date objects (a TimeClip'd millisecond * time value with identity): getters, text forms and setters. @@ -208,6 +208,19 @@ export function dateValueMethod(name: string, value: IrExpr, loc: SrcLoc): IrExp if (textFn !== undefined) { return { kind: "libCall", fn: textFn, args: [value], type: STRING, loc }; } + // The default locale's forms. The spec makes them DateTimeFormat(locale, + // options).format(this); with neither argument that is ICU's default + // locale over the host time zone, which scr_intl.c formats over the + // ICU release exactly as V8 does. + if (name === "toLocaleString" || name === "toLocaleTimeString") { + return { + kind: "libCall", + fn: name === "toLocaleString" ? "icu.dateLocaleString" : "icu.dateLocaleTimeString", + args: [value], + type: STRING, + loc, + }; + } return null; } diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2eac7a17d..9abffc155 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -569,9 +569,10 @@ export function lowerProcessProperty( // The capability-probe members that honestly DON'T EXIST in a // compiled binary — each reads undefined (their declared types carry // the undefined arm), so feature probes take their documented - // fallbacks: no gyp build config (process.config.variables.* — no ICU, - // no QUIC — and process.config.target_defaults), no feature flags - // (process.features.* — no inspector, not a debug build). + // fallbacks: no gyp build config (process.config.variables.* — no QUIC + // — and process.config.target_defaults; v8_enable_i18n_support is the + // exception below, answered from the binary's linked ICU), no feature + // flags (process.features.* — no inspector, not a debug build). if (!expr.questionDotToken && ts.isPropertyAccessExpression(expr.expression)) { const container = lowerer.stdlibGlobalMember(expr.expression, "process"); if (container === "features") { @@ -594,6 +595,13 @@ export function lowerProcessProperty( expr.expression.name.text === "variables" && lowerer.stdlibGlobalMember(expr.expression.expression, "process") === "config" ) { + // The one variable a compiled binary can answer truthfully: + // v8_enable_i18n_support. Node bundles ICU and answers the number 1; + // this read selects the `icu` runtime feature (scr_intl.c), so a + // binary that reports ICU is a binary that carries it. + if (expr.name.text === "v8_enable_i18n_support") { + return { kind: "libCall", fn: "icu.i18nSupport", args: [], type: F64, loc: locOf(expr) }; + } return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(expr) }; } } diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..47ce18bf8 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -822,7 +822,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: "Proxy constructor values have no native lowering; use a direct new Proxy with checked-native plain objects", Reflect: "reflective property access has no static lowering — read and call members directly", - Intl: 'native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported', + Intl: 'native Intl supports Intl.Segmenter with grapheme or word granularity and the composed new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString(); formatter objects, locale negotiation and the other Intl constructors remain unsupported', SharedArrayBuffer: "use direct new SharedArrayBuffer(length) for fixed shared storage", ArrayBuffer: "ArrayBuffer constructor values have no native lowering; use new ArrayBuffer(length) for fixed-length shared storage", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index de5bab8b7..4dc3f431e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5472,24 +5472,54 @@ function lowerPrimitiveProtoCall( } // Number.prototype.toLocaleString("en-US") — the spec makes it // NumberFormat(locale).format(this), so the en-US embedded formatter - // answers exactly. The unlowered forms fence by NAME: no locale (the - // host environment's default, which a compiled binary cannot carry), - // other locales (ICU data the binary does not embed), options bags. + // answers exactly. Without a locale (or with `undefined`) the DEFAULT + // locale answers, over the ICU data scr_intl.c loads; other locales and + // options bags stay fenced. if (name === "toLocaleString" && recvKind === "f64") { - if (call.arguments.nodes.length === 0) { - lowerer.noLowering( - "Number.prototype.toLocaleString without a locale", - call, - "the default locale is the host environment's, which a compiled binary cannot carry — " + - 'pass it explicitly: x.toLocaleString("en-US")', - ); + // Exactly undefined (or void): what the absent form means. A union that + // merely includes undefined may hold a locale at run time and falls + // through to the refusal below. + const isUndefined = (node: ts.Expression): boolean => { + const flags = lowerer.typeOf(node).flags; + return flags === ts.TypeFlags.Undefined || flags === ts.TypeFlags.Void; + }; + const defaultLocale = + call.arguments.nodes.length === 0 || + (call.arguments.nodes.length === 1 && + !ts.isSpreadElement(call.arguments.nodes[0]!) && + isUndefined(call.arguments.nodes[0]!)); + if (defaultLocale) { + const operand = lowerer.lowerExpr(recv); + if (operand.type.kind !== "f64") return null; + const stmts: IrStmt[] = []; + if (call.arguments.nodes.length === 1) { + // An explicit undefined may be any expression that evaluates to it + // (a call, a variable), so it still runs — in argument order, before + // the formatting. Its unit value has no standalone representation, + // so it is coerced into the dynamic slot every argument occupies. + const arg = call.arguments.nodes[0]!; + const value = lowerer.lowerExpr(arg); + stmts.push({ + kind: "exprStmt", + expr: value.type.kind === "void" ? value : lowerer.coerceInto(arg, value, DYN), + loc, + }); + } + const call_: IrExpr = { + kind: "libCall", + fn: "icu.numFormatDefault", + args: [operand], + type: STRING, + loc, + }; + return stmts.length ? { kind: "seqExpr", stmts, result: call_, type: STRING, loc } : call_; } if (call.arguments.nodes.length > 1) { lowerer.noLowering( "Number.prototype.toLocaleString with an options bag", call, - "the embedded data covers DEFAULT options only (decimal notation, up to 3 fraction " + - 'digits, grouping) — x.toLocaleString("en-US")', + "what lowers is the DEFAULT options shape (decimal notation, up to 3 fraction digits, " + + "grouping) at the default locale or at en-US — omit the options bag", ); } const locNode = call.arguments.nodes[0]!; @@ -5503,8 +5533,8 @@ function lowerPrimitiveProtoCall( ? `Number.prototype.toLocaleString at locale "${locNode.text}"` : "Number.prototype.toLocaleString with a non-literal locale", locNode, - '"en-US" (Node\'s default-build locale) is the one locale whose data the runtime embeds — ' + - "everything else is ICU data the binary does not carry", + "what lowers is the default locale (no argument) and the composed en-US form — " + + "other locales are ICU data the binary does not negotiate yet", ); } const operand = lowerer.lowerExpr(recv); diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..a4fe7ed5b 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -10264,8 +10264,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { lowerer.isStdlibGlobal(expr.expression.expression, "Intl") ) { const args = expr.arguments?.nodes ?? []; - const undefinedArg = (arg: ts.Expression): boolean => - (lowerer.typeOf(arg).flags & ts.TypeFlags.Undefined) !== 0; + // An argument whose value IS undefined: exactly undefined, or void. A + // union that merely includes undefined may hold a locale or an options + // object at run time, and the constructor cannot inspect either — such + // an argument is refused below rather than silently read as the default + // locale or the default options. + const undefinedArg = (arg: ts.Expression): boolean => { + const flags = lowerer.typeOf(arg).flags; + return flags === ts.TypeFlags.Undefined || flags === ts.TypeFlags.Void; + }; // A literal language tag: Unicode grapheme clusters have no locale // tailoring. Only tags of the form language[-script][-region] are // accepted; each is well-formed, so Node constructs the segmenter @@ -10280,13 +10287,14 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { "omit the locale, pass undefined, or pass one language tag as a string literal", ); } - // JavaScript dependencies may construct word and sentence segmenters - // that they use only on some paths (pi-tui's shared word segmenter): - // the construction compiles and their segment() throws SC2020. - // TypeScript sources keep the build-time refusal. + // Word segmentation lowers through ICU (scr_intl.c). Sentence + // segmentation does not: JavaScript dependencies may construct sentence + // segmenters they use only on some paths, so the construction compiles + // and their segment() throws SC2020; TypeScript sources keep the + // build-time refusal. const granularities = isJsSourceFile(ts.getSourceFileOfNode(expr)) ? ["grapheme", "word", "sentence"] - : ["grapheme"]; + : ["grapheme", "word"]; const options = args[1]; let granularity = "grapheme"; if (options && !undefinedArg(options)) { @@ -10296,7 +10304,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { lowerer.noLowering( "Intl.Segmenter options", options, - 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported', + 'the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported', ); for (const property of options.properties.nodes) { if ( @@ -10309,7 +10317,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { lowerer.noLowering( "Intl.Segmenter options", options, - 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported', + 'the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported', ); } granularity = property.initializer.text; @@ -10324,16 +10332,32 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { loc: locOf(arg), }; }); + // Grapheme segmentation is the engine-free table-driven segmenter + // (scr_grapheme.c). NOTE: once ICU is established everywhere, this fast + // path is deleted in favor of ICU's character iterator (design I4). let result: IrExpr = { kind: "libCall", fn: "intl.segmenterNew", args: [], type: DYN, loc }; - // A word or sentence segmenter carries its segment() refusal, which - // coverage lists as deferred to runtime. - if (granularity !== "grapheme") { + if (granularity === "word") { + // ICU's word iterator at the requested locale; a literal language tag + // is the locale, and the empty string means the default locale (what + // Node negotiates when the argument is absent or undefined). + const tag = args[0] !== undefined && ts.isStringLiteralLike(args[0]) ? args[0].text : ""; + result = { + kind: "libCall", + fn: "icu.segmenterWord", + args: [strLit(tag, loc)], + type: DYN, + loc, + }; + } + // A sentence segmenter carries its segment() refusal, which coverage + // lists as deferred to runtime. + if (granularity === "sentence") { const surface = `Intl.Segmenter segmentation with granularity '${granularity}'`; lowerer.runtimeFences.push( noLoweringDiag( surface, loc, - "only grapheme segmenters segment; word and sentence segmenters construct", + "only grapheme and word segmenters segment; sentence segmenters construct", ), ); const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(expr), loc.start).line; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..3aad833a8 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11295,9 +11295,11 @@ export class Lowerer { // pointed reason the identifier/constructor chokepoints teach. if (stdlibNominal) { const intlHint = - "Intl formatter values have no representation — the COMPOSED en-US forms lower: " + - 'new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options; ' + - "the rest is ICU locale data the binary does not carry"; + "Intl formatter values have no representation — the COMPOSED forms lower: " + + 'new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and ' + + "x.toLocaleString() with default options, d.toLocaleString(), d.toLocaleTimeString(), " + + "and Intl.Segmenter with grapheme or word granularity; the formatter objects " + + "themselves (locale negotiation, resolvedOptions, options bags) remain unsupported"; const typeHints: Record = { ArrayBuffer: "fixed ArrayBuffer storage and typed-array views are supported; resizable and detached storage are not modeled", diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..b557b3767 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -3417,11 +3417,13 @@ export function stdlibMemberFence(lowerer: Lowerer, access: ts.PropertyAccessExp member === "toLocaleUpperCase" ) { hint = - "locale- and ICU-backed behavior lives outside the static runtime (the localeCompare " + - "stance: code-unit order, no collation/locale data) — what lowers: the composed " + + "collation and locale negotiation stay outside the static runtime (the localeCompare " + + "stance: code-unit order, no collation) — what lowers: the composed " + 'new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default ' + - "options, plus default Unicode grapheme segmentation with Intl.Segmenter; locale " + - "negotiation, word/sentence segmentation, and resolvedOptions remain unsupported"; + "options, their default-locale forms (x.toLocaleString(), d.toLocaleString(), " + + "d.toLocaleTimeString()), and Intl.Segmenter with grapheme or word granularity; " + + "sentence segmentation, resolvedOptions, and the remaining Intl constructors remain " + + "unsupported"; } else if (container === "Object" && member === "assign") { hint = "spread instead: { ...a, ...b } builds the merged record; what lowers: the empty-target " + diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..ab382aaca 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1000,6 +1000,18 @@ function buildLibFnSignatures() { * grouping every three integer digits, "∞"/"NaN" texts, and "-0" for * negative inputs rounding to zero. Result +1; never throws. */ "intl.numFormatEnUs": { argTypes: [F64], result: STRING }, + /** `x.toLocaleString()` at the default locale: the same ICU number + * engine V8 uses, over the full ICU data (scr_intl.c). Result +1. */ + "icu.numFormatDefault": { argTypes: [F64], result: STRING }, + /** `process.config.variables.v8_enable_i18n_support`: the number 1, as + * Node (which bundles ICU) answers. Reading it selects the `icu` + * runtime feature, so the binary that reports ICU is the binary that + * carries it (scr_intl.c). */ + "icu.i18nSupport": { argTypes: [], result: F64 }, + /** `d.toLocaleString()` / `d.toLocaleTimeString()` at the default + * locale over `d`'s time value (scr_intl.c). Result +1. */ + "icu.dateLocaleString": { argTypes: [DATE_T], result: STRING }, + "icu.dateLocaleTimeString": { argTypes: [DATE_T], result: STRING }, /** ES Symbol values (scr_symbol.c — link-gated by moduleUsesSymbol). * sym.new: `Symbol(desc)` — a fresh runtime-unique identity (+1) whose * one arg is the description string (borrowed); sym.newAnon is the @@ -3939,6 +3951,13 @@ function buildLibFnSignatures() { "ffi.memoryModule": { argTypes: [DYN], result: DYN }, "intl.segmenterNew": { argTypes: [], result: DYN }, "intl.segmenterRefusing": { argTypes: [STRING], result: DYN }, + /** `new Intl.Segmenter(locale?, { granularity: "word" })`: ICU's UAX + * #29 word iterator (scr_intl.c over ICU), which the + * table-driven grapheme segmenter above cannot express. The argument is + * the negotiated language tag; the empty string selects the default + * locale (ICU's, resolved from the host environment like V8's). +1 + * handle. */ + "icu.segmenterWord": { argTypes: [STRING], result: DYN }, "arrayBuffer.is": { argTypes: [DYN], result: BOOL }, "arrayBuffer.isView": { argTypes: [DYN], result: BOOL }, "arrayBuffer.byteLengthGetter": { argTypes: [], result: F64 }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..f381ca896 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1082,6 +1082,12 @@ const RUNTIME_FEATURE_RULES = { libCall: (node) => node.fn.startsWith("http2.") && !HTTP2_LEGACY_FNS.has(node.fn), types: ["http2Session", "http2Stream"], }), + /** Intl over ICU: scr_intl.c plus the ICU archive and the data + * file that ships beside the executable. The prefix is `icu.` because the + * `intl.` libCalls are the engine-free fast paths — the embedded en-US + * number format and the table-driven grapheme segmenter — which must keep + * linking no ICU code. */ + icu: rule({ prefixes: ["icu."] }), /** scr_test.c renders thrown strings with the inspect unit's string * formatting; scr_util_callable.c wraps the inspect unit; readline's * terminal unit prints completion errors through it when there is a @@ -1288,6 +1294,7 @@ function noRuntimeFeatures(): RuntimeFeatures { fsWatch: false, http2: false, http: false, + icu: false, inspect: false, legacyTextDecoder: false, mime: false, diff --git a/packages/compiler/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts index 2337c41ad..f30fb953d 100644 --- a/packages/compiler/src/library/ir-policy.ts +++ b/packages/compiler/src/library/ir-policy.ts @@ -91,6 +91,9 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [ ["worker.receiveMessageOnPort", "native worker threads"], ["worker.setEnvironmentData", "native worker threads"], ["worker.getEnvironmentData", "native worker threads"], + // Intl over ICU reads ambient locale state and a data file that ships + // beside an executable; a library archive carries neither. + ["icu.", "the Intl surface at the default locale (ICU data and locale state are ambient)"], ["worker.markUntransferable", "native worker threads"], ["worker.isMarkedUntransferable", "native worker threads"], ["worker.broadcastChannel", "native worker threads"], diff --git a/tests/coverage-fixtures/dynamic-mix.ts b/tests/coverage-fixtures/dynamic-mix.ts index d70141b91..c7d96840a 100644 --- a/tests/coverage-fixtures/dynamic-mix.ts +++ b/tests/coverage-fixtures/dynamic-mix.ts @@ -1,9 +1,9 @@ // Native checked values and loose equality share this program with -// native compilation refusals (a default-locale toLocaleString, SC2020). +// native compilation refusals (a non-en-US toLocaleString, SC2020). const v: any = 21; const doubled = v * 2; const root = Math.cbrt(81); -const up = (19.99).toLocaleString(); +const up = (19.99).toLocaleString("de-DE"); const parsed = Number.parseFloat("1.5"); // a string argument compiles statically, like the global const unknownScore: unknown = 81; const flags = unknownScore == 81; diff --git a/tests/coverage-fixtures/tier-demotions.ts b/tests/coverage-fixtures/tier-demotions.ts index 3dbd0c3fe..8fa77d64f 100644 --- a/tests/coverage-fixtures/tier-demotions.ts +++ b/tests/coverage-fixtures/tier-demotions.ts @@ -17,9 +17,9 @@ function readName(text: string): string { return String(data.name); } -// An operation without a static lowering (the host's default locale), on a parameter. +// An operation without a static lowering (ICU locale negotiation), on a parameter. function precise(n: number): string { - return n.toLocaleString(); + return n.toLocaleString("de-DE"); } console.log(readName('{"name":"a"}'), precise(1.5), new Point(1, 2).x); diff --git a/tests/diagnostics/date-formatters.ts b/tests/diagnostics/date-formatters.ts index e803b773f..ddbe89045 100644 --- a/tests/diagnostics/date-formatters.ts +++ b/tests/diagnostics/date-formatters.ts @@ -1,5 +1,7 @@ // Unsupported formatters must reach the Date method diagnostic, including // names inherited from Object.prototype by the compiler's dispatch table. +// The default-locale toLocaleString()/toLocaleTimeString() lower now +// (scr_intl.c); a locale or options argument still has none. const date = new Date(0); -date.toLocaleTimeString(); -date.toLocaleString(); +date.toLocaleDateString(); +date.toLocaleString("en-US"); diff --git a/tests/diagnostics/dynamic-surface.ts b/tests/diagnostics/dynamic-surface.ts index 3af6671f4..1a3bf33fd 100644 --- a/tests/diagnostics/dynamic-surface.ts +++ b/tests/diagnostics/dynamic-surface.ts @@ -6,10 +6,10 @@ // trim/pad variants, parseInt, isNaN, the global parseFloat/isFinite and // Number.parseFloat/Number.parseInt over exactly-typed arguments, // string-pattern replacement and toPrecision compile statically now and no -// longer appear here; toLocaleString without a locale still has no -// lowering.) +// longer appear here; toLocaleString at an explicit non-en-US locale still +// has no lowering (the default-locale form lowers over ICU).) const up = Math.cbrt(2); const tau = Math.PI * 2; -const price = (19.99).toLocaleString(); +const price = (19.99).toLocaleString("de-DE"); const n = Number.parseFloat("3.14"); void n; diff --git a/tests/diagnostics/intl-segmenter.ts b/tests/diagnostics/intl-segmenter.ts index 0cf30eabb..9e8676a1a 100644 --- a/tests/diagnostics/intl-segmenter.ts +++ b/tests/diagnostics/intl-segmenter.ts @@ -1,5 +1,7 @@ -// The native implementation covers Unicode grapheme segmentation. +// The native implementation covers Unicode grapheme segmentation, and ICU +// answers word segmentation; locale negotiation over several tags and +// sentence segmentation stay fenced. new Intl.Segmenter(["en-US", "de"]); -new Intl.Segmenter(undefined, { granularity: "word" }); +new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); new Intl.Segmenter(undefined, { granularity: "sentence" }); // End of the diagnostic fixture. diff --git a/tests/diagnostics/intl.ts b/tests/diagnostics/intl.ts index b4adad8a6..a5cabfc1e 100644 --- a/tests/diagnostics/intl.ts +++ b/tests/diagnostics/intl.ts @@ -1,8 +1,8 @@ // The Intl surface. What lowers: the COMPOSED en-US NumberFormat forms // with default options — new Intl.NumberFormat("en-US").format(x) and -// x.toLocaleString("en-US"). Everything else fences by NAME: the -// environment-default locale a compiled binary cannot carry, other -// locales (ICU data the binary does not embed), options bags, formatter +// x.toLocaleString("en-US") — plus the default locale's own forms over ICU +// (x.toLocaleString(), d.toLocaleString()). Everything else fences by +// NAME: other locales (ICU locale negotiation), options bags, formatter // VALUES, and the rest of the Intl namespace (DurationFormat, // DateTimeFormat, PluralRules, getCanonicalLocales). @@ -14,7 +14,6 @@ const hoisted = new Intl.NumberFormat("en-US"); const viaValue = hoisted.format(1234.5); // toLocaleString's unlowered variants. -const envDefault = (1234.5).toLocaleString(); const german = (1234.5).toLocaleString("de-DE"); const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index b3eff41f5..190e153c3 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -38,7 +38,7 @@ const arrAt = [1, 2].at(0); const norm = "abc".normalize(); const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) -const localized = (1234.5).toLocaleString(); +const localized = (1234.5).toLocaleString("de-DE"); // Unlowered call FORMS of lowered members. (push is fully variadic now — // no fenced form remains to pin.) const nums = [1, 2, 3]; diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 82e541140..05d076f6f 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -407,13 +407,12 @@ test("--explain-dynamic attributes each refusal to the unit the dynamic tier wou requests.map((r) => [r.code, r.seed, r.scope, r.unit.kind, r.unit.name, r.enclosing.name]), ).toEqual([ ["SC1090", "reflection", "reached", "class", "Point", ""], - ["SC2020", "operation", "reached", "binding", "n", "precise"], + ["SC2020", "operation", "reached", "function", "precise", "precise"], ]); expect(tiers.units.map((u) => [u.kind, u.name, u.requests, u.sites])).toEqual([ ["class", "Point", 1, 0], ["function", "", 0, 1], - ["function", "precise", 0, 1], - ["binding", "n", 1, 0], + ["function", "precise", 1, 1], ]); const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); expect(envelope.tiers?.requests[0]).toMatchObject({ unit: { kind: expect.any(String) } }); @@ -534,10 +533,10 @@ test("CLI: --tier and --explain-dynamic", () => { expect(explained.status).toBe(0); expect(explained.stdout).toContain("in dynamic tier 1"); expect(explained.stdout).toContain("--tier=static-only"); - expect(explained.stdout).toContain("binding n in precise"); + expect(explained.stdout).toContain("function precise"); const one = run("coverage", fixture("tier-demotions.ts"), "--explain-dynamic=Point"); expect(one.stdout).toContain("class Point"); - expect(one.stdout).not.toContain("binding n in precise"); + expect(one.stdout).not.toContain("function precise"); const bad = run("coverage", fixture("tier-demotions.ts"), "--tier=dynamic-only"); expect(bad.status).toBe(1); expect(bad.stderr).toContain('unknown --tier value "dynamic-only"'); From 995eb54dbf20be2fa419cf7561acece02b0a2551 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:20 -0700 Subject: [PATCH 019/193] Pin the Intl-over-ICU forms against Node in the corpus Three corpus programs execute the new forms under both Node and the compiled binary: default-locale number formatting (grouping, CLDR fraction digits, infinities, -0, the capability probe), default-locale Date formatting and time formatting, and word segmentation (segments, isWordLike, re-iteration, containing). Their ts7 order/preflight baselines record each as one module with no diagnostics. The harness learns two directives: `@env: NAME=VALUE` pins TZ/LC_ALL for BOTH sides (the directive is part of the program bytes the oracle cache key hashes), and `@requires-icu` skips programs on hosts whose packs do not carry ICU in this slice. The npm-static word-segmenter pilot no longer expects the package's load-time segmenter to fence into a Node island: word segmentation lowers now, so the package compiles statically (checked on the linux-x64 pack; other machines check the analysis and stop before the link). The coverage snapshots and the mixed/tier fixtures move with the narrowed refusal text. Co-Authored-By: Claude Opus 5.5 --- .../intl-date-tolocalestring-default.ts.json | 6 ++ ...intl-number-tolocalestring-default.ts.json | 6 ++ .../tests/corpus/intl-word-segmenter.ts.json | 6 ++ .../intl-date-tolocalestring-default.ts | 30 +++++++++ .../intl-number-tolocalestring-default.ts | 34 ++++++++++ tests/corpus/intl-word-segmenter.ts | 45 +++++++++++++ .../__snapshots__/coverage-dynamic-mix.txt | 6 +- .../coverage-explain-dynamic.txt | 6 +- .../__snapshots__/coverage-tier-demotions.txt | 6 +- .../__snapshots__/date-formatters.ts.txt | 22 +++---- .../__snapshots__/dynamic-surface.ts.txt | 8 +-- .../__snapshots__/intl-segmenter.ts.txt | 30 ++++----- tests/harness/__snapshots__/intl.ts.txt | 65 ++++++++----------- .../harness/__snapshots__/stdlib-fence.ts.txt | 10 +-- .../intl-date-tolocalestring-default.ts.json | 1 + ...intl-number-tolocalestring-default.ts.json | 1 + .../corpus-costs/intl-word-segmenter.ts.json | 1 + tests/harness/differential.test.ts | 52 +++++++++++++-- tests/harness/llvm-differential.test.ts | 49 ++++++++++++-- tests/harness/npm-static.test.ts | 18 +++-- 20 files changed, 302 insertions(+), 100 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json create mode 100644 tests/corpus/intl-date-tolocalestring-default.ts create mode 100644 tests/corpus/intl-number-tolocalestring-default.ts create mode 100644 tests/corpus/intl-word-segmenter.ts create mode 100644 tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json create mode 100644 tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json create mode 100644 tests/harness/corpus-costs/intl-word-segmenter.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json new file mode 100644 index 000000000..8bd66074e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-date-tolocalestring-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json new file mode 100644 index 000000000..914d291a1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-number-tolocalestring-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json new file mode 100644 index 000000000..a38a2d192 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-word-segmenter.ts" + ], + "diags": [] +} diff --git a/tests/corpus/intl-date-tolocalestring-default.ts b/tests/corpus/intl-date-tolocalestring-default.ts new file mode 100644 index 000000000..3cca6e104 --- /dev/null +++ b/tests/corpus/intl-date-tolocalestring-default.ts @@ -0,0 +1,30 @@ +// @requires-icu +// @env: TZ=UTC,LC_ALL=de_DE.UTF-8 +// Date.prototype.toLocaleString() and toLocaleTimeString() with no +// arguments: the DEFAULT locale's date-time and time patterns, in the host +// time zone (scr_intl.c asks ICU for the best pattern of the skeleton V8 +// asks for, "yMdjms" and "jms", and applies V8's U+202F → U+0020 revert). +// The corpus pins TZ and LC_ALL so the two sides read the same zone and +// locale; everything below — field order, separators, the 24-hour cycle +// this locale prefers, and the Invalid Date text — compares byte for byte. +const dates = [ + new Date(0), + new Date(1700000000000), + new Date(1234567890123), + new Date(2024, 1, 29, 0, 0, 0), + new Date(1999, 11, 31, 23, 59, 59, 999), +]; +for (const d of dates) console.log(d.toLocaleString(), "|", d.toLocaleTimeString()); + +// The morning/afternoon boundary and the yMdjms pattern's own separators. +console.log(new Date(2023, 10, 14, 9, 5, 7).toLocaleString()); +console.log(new Date(2023, 10, 14, 13, 5, 7).toLocaleString()); +console.log(new Date(2023, 10, 14, 13, 5, 7).toLocaleTimeString()); + +// An invalid date answers "Invalid Date" on both methods. +const invalid = new Date(NaN); +console.log(invalid.toLocaleString(), "|", invalid.toLocaleTimeString()); + +// The results are ordinary strings. +const label = `Zeit: ${new Date(1700000000000).toLocaleTimeString()}`; +console.log(label, label.length, new Date(0).toLocaleString().length); diff --git a/tests/corpus/intl-number-tolocalestring-default.ts b/tests/corpus/intl-number-tolocalestring-default.ts new file mode 100644 index 000000000..e63a9373a --- /dev/null +++ b/tests/corpus/intl-number-tolocalestring-default.ts @@ -0,0 +1,34 @@ +// @requires-icu +// @env: LC_ALL=de_DE.UTF-8 +// Number.prototype.toLocaleString() at the DEFAULT locale, which is the one +// form of the method that only ICU can answer (scr_intl.c over the vendored +// ICU archive): grouping, the decimal separator and the CLDR fraction-digit +// rules come from the locale's data, not from the engine-free en-US path +// that 2562-intl-numberformat-en-us.ts pins. The bare call and an explicit +// undefined are the same operation in EMCAScript; both lower here. +const xs = [0, 1, 100, 1000, 1234.5, 1234567.891, -9876543.21, 0.5, 1e6, 1e21]; +for (const x of xs) console.log(x.toLocaleString()); +console.log((1234.5).toLocaleString(undefined), (-0).toLocaleString()); +console.log((0.0625).toLocaleString(), (1.0005).toLocaleString(), (7.995).toLocaleString()); +console.log((NaN).toLocaleString(), (1 / 0).toLocaleString(), (-1 / 0).toLocaleString()); +console.log((5e-4).toLocaleString(), (1e-7).toLocaleString(), (5e-324).toLocaleString()); + +// The result is an ordinary string: template interpolation, concatenation +// and methods all see the formatted text. +const label = `${(1234.5).toLocaleString()} Einheiten`; +console.log(label, label.length, (42).toLocaleString().padStart(8, ".")); +const mapped = xs.map((x) => x.toLocaleString()); +console.log(mapped.join("|")); + +// An explicit undefined argument may carry effects and is evaluated first. +let order = ""; +function undef(value: string): undefined { + order += value; + return undefined; +} +console.log((1234.5).toLocaleString(undef("a")), order); + +// The capability probe Node answers from its own ICU build: an Intl program +// links ICU, so the binary's answer is Node's (the number 1), and reading it +// is what links ICU. +console.log(process.config.variables.v8_enable_i18n_support); diff --git a/tests/corpus/intl-word-segmenter.ts b/tests/corpus/intl-word-segmenter.ts new file mode 100644 index 000000000..cb455d42c --- /dev/null +++ b/tests/corpus/intl-word-segmenter.ts @@ -0,0 +1,45 @@ +// @requires-icu +// @env: LC_ALL=en_US.UTF-8 +// Intl.Segmenter({ granularity: "word" }) — ICU's UAX #29 word break +// iterator (scr_intl.c over the ICU archive), which is the one +// Intl form pi's tui layer needs: the segments carry isWordLike, exactly as +// Node's do. Word boundaries at the default locale and under a locale tag, +// punctuation and digits, re-iteration, and the containing() lookup all pin +// byte-identically against Node. +const text = "Hello, world! It's 3.14 — don't stop, Übermensch."; +const segments = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text); +for (const { segment, index, input, isWordLike } of segments) { + console.log(JSON.stringify(segment), index, input === text, isWordLike); +} + +// The same input through a locale tag: German word breaking is still +// UAX #29 root rules at heart, but the segmenter must accept the tag. +const german = new Intl.Segmenter("de", { granularity: "word" }).segment("Straße 42, größer."); +let words = ""; +let wordCount = 0; +for (const part of german) { + if (part.isWordLike === true) { + words += `${wordCount === 0 ? "" : "|"}${part.segment}`; + wordCount += 1; + } +} +console.log(words, wordCount); + +// Re-iterating one Segments object restarts from the first segment. +const again = new Intl.Segmenter("en", { granularity: "word" }).segment("one two"); +let first = ""; +for (const part of again) first += `[${part.segment}]`; +let second = ""; +for (const part of again) second += `[${part.segment}]`; +console.log(first, second, first === second); + +// containing() answers the segment covering a position and undefined past +// the end, the same contract as the grapheme segmenter's. +const lookup = new Intl.Segmenter("en", { granularity: "word" }).segment("a b"); +for (const position of [-1, 0, 1, 2, 3, 4]) { + const result = lookup.containing(position); + console.log( + position, + result === undefined ? "none" : `${result.index}:${result.segment}:${result.isWordLike}`, + ); +} diff --git a/tests/harness/__snapshots__/coverage-dynamic-mix.txt b/tests/harness/__snapshots__/coverage-dynamic-mix.txt index 0bff0f03c..175f85bc9 100644 --- a/tests/harness/__snapshots__/coverage-dynamic-mix.txt +++ b/tests/harness/__snapshots__/coverage-dynamic-mix.txt @@ -5,6 +5,6 @@ scriptc coverage tests/coverage-fixtures/dynamic-mix.ts in dynamic tier 2 (operate on values typed only at runtime; --explain-dynamic lists them) blockers: - ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - at tests/coverage-fixtures/dynamic-mix.ts:6:12 - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file + ×1 SC2020 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet + at tests/coverage-fixtures/dynamic-mix.ts:6:35 + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-explain-dynamic.txt b/tests/harness/__snapshots__/coverage-explain-dynamic.txt index 6e561634d..5cab27066 100644 --- a/tests/harness/__snapshots__/coverage-explain-dynamic.txt +++ b/tests/harness/__snapshots__/coverage-explain-dynamic.txt @@ -10,7 +10,5 @@ <- SC1090 reflection at tests/coverage-fixtures/tier-demotions.ts:10:1: assignment to non-variables are not supported yet -> function turns dynamic function tests/coverage-fixtures/tier-demotions.ts:1:1 (1 site inside) - function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 site inside) - binding n in precise tests/coverage-fixtures/tier-demotions.ts:21:18 (1 request) - <- SC2020 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - -> function precise turns dynamic \ No newline at end of file + function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 request, 1 site inside) + <- SC2020 operation at tests/coverage-fixtures/tier-demotions.ts:22:27: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-tier-demotions.txt b/tests/harness/__snapshots__/coverage-tier-demotions.txt index aaf1f4e3b..19b712dbf 100644 --- a/tests/harness/__snapshots__/coverage-tier-demotions.txt +++ b/tests/harness/__snapshots__/coverage-tier-demotions.txt @@ -8,6 +8,6 @@ scriptc coverage tests/coverage-fixtures/tier-demotions.ts ×1 SC1090 assignment to non-variables at tests/coverage-fixtures/tier-demotions.ts:10:1 hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) - ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - at tests/coverage-fixtures/tier-demotions.ts:22:10 - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file + ×1 SC2020 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet + at tests/coverage-fixtures/tier-demotions.ts:22:27 + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/date-formatters.ts.txt b/tests/harness/__snapshots__/date-formatters.ts.txt index d6dd27c5a..2cbd739d9 100644 --- a/tests/harness/__snapshots__/date-formatters.ts.txt +++ b/tests/harness/__snapshots__/date-formatters.ts.txt @@ -1,17 +1,17 @@ -date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toLocaleTimeString' is part of the standard library types but has no scriptc lowering yet +date-formatters.ts:6:1 - error SC2020: 'Date.prototype.toLocaleDateString' is part of the standard library types but has no scriptc lowering yet - 3 | const date = new Date(0); - 4 | date.toLocaleTimeString(); + 5 | const date = new Date(0); + 6 | date.toLocaleDateString(); | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 5 | date.toLocaleString(); + 7 | date.toLocaleString("en-US"); - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering -date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet +date-formatters.ts:7:1 - error SC2020: 'Date.prototype.toLocaleString with arguments' is part of the standard library types but has no scriptc lowering yet - 4 | date.toLocaleTimeString(); - 5 | date.toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~ - 6 | + 6 | date.toLocaleDateString(); + 7 | date.toLocaleString("en-US"); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 8 | - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering \ No newline at end of file + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/dynamic-surface.ts.txt b/tests/harness/__snapshots__/dynamic-surface.ts.txt index d4fe2ac7e..47834fe92 100644 --- a/tests/harness/__snapshots__/dynamic-surface.ts.txt +++ b/tests/harness/__snapshots__/dynamic-surface.ts.txt @@ -1,8 +1,8 @@ -dynamic-surface.ts:13:15 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +dynamic-surface.ts:13:38 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet 12 | const tau = Math.PI * 2; - 13 | const price = (19.99).toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~~~~ + 13 | const price = (19.99).toLocaleString("de-DE"); + | ^~~~~~~ 14 | const n = Number.parseFloat("3.14"); - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/intl-segmenter.ts.txt b/tests/harness/__snapshots__/intl-segmenter.ts.txt index 19a13ef4f..88f442df9 100644 --- a/tests/harness/__snapshots__/intl-segmenter.ts.txt +++ b/tests/harness/__snapshots__/intl-segmenter.ts.txt @@ -1,26 +1,26 @@ -intl-segmenter.ts:2:1 - error SC2020: 'Intl.Segmenter locale negotiation' is part of the standard library types but has no scriptc lowering yet +intl-segmenter.ts:4:1 - error SC2020: 'Intl.Segmenter locale negotiation' is part of the standard library types but has no scriptc lowering yet - 1 | // The native implementation covers Unicode grapheme segmentation. - 2 | new Intl.Segmenter(["en-US", "de"]); + 3 | // sentence segmentation stay fenced. + 4 | new Intl.Segmenter(["en-US", "de"]); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 3 | new Intl.Segmenter(undefined, { granularity: "word" }); + 5 | new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); hint: omit the locale, pass undefined, or pass one language tag as a string literal -intl-segmenter.ts:3:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet +intl-segmenter.ts:5:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet - 2 | new Intl.Segmenter(["en-US", "de"]); - 3 | new Intl.Segmenter(undefined, { granularity: "word" }); - | ^~~~~~~~~~~~~~~~~~~~~~~ - 4 | new Intl.Segmenter(undefined, { granularity: "sentence" }); + 4 | new Intl.Segmenter(["en-US", "de"]); + 5 | new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 6 | new Intl.Segmenter(undefined, { granularity: "sentence" }); - hint: the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported + hint: the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported -intl-segmenter.ts:4:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet +intl-segmenter.ts:6:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet - 3 | new Intl.Segmenter(undefined, { granularity: "word" }); - 4 | new Intl.Segmenter(undefined, { granularity: "sentence" }); + 5 | new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); + 6 | new Intl.Segmenter(undefined, { granularity: "sentence" }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ - 5 | // End of the diagnostic fixture. + 7 | // End of the diagnostic fixture. - hint: the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported \ No newline at end of file + hint: the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported \ No newline at end of file diff --git a/tests/harness/__snapshots__/intl.ts.txt b/tests/harness/__snapshots__/intl.ts.txt index b26d81dbc..d4be83e17 100644 --- a/tests/harness/__snapshots__/intl.ts.txt +++ b/tests/harness/__snapshots__/intl.ts.txt @@ -32,7 +32,7 @@ intl.ts:13:7 - error SC2020: 'NumberFormat' is part of the standard library type | ^~~~~~~ 14 | const viaValue = hoisted.format(1234.5); - hint: Intl formatter values have no representation — the COMPOSED en-US forms lower: new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options; the rest is ICU locale data the binary does not carry + hint: Intl formatter values have no representation — the COMPOSED forms lower: new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString() with default options, d.toLocaleString(), d.toLocaleTimeString(), and Intl.Segmenter with grapheme or word granularity; the formatter objects themselves (locale negotiation, resolvedOptions, options bags) remain unsupported intl.ts:13:17 - error SC2020: 'new Intl.NumberFormat' is part of the standard library types but has no scriptc lowering yet @@ -50,67 +50,58 @@ intl.ts:14:18 - error SC2020: 'NumberFormat.format' is part of the standard libr | ^~~~~~~~~~~~~~ 15 | - hint: locale- and ICU-backed behavior lives outside the static runtime (the localeCompare stance: code-unit order, no collation/locale data) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, plus default Unicode grapheme segmentation with Intl.Segmenter; locale negotiation, word/sentence segmentation, and resolvedOptions remain unsupported + hint: collation and locale negotiation stay outside the static runtime (the localeCompare stance: code-unit order, no collation) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, their default-locale forms (x.toLocaleString(), d.toLocaleString(), d.toLocaleTimeString()), and Intl.Segmenter with grapheme or word granularity; sentence segmentation, resolvedOptions, and the remaining Intl constructors remain unsupported -intl.ts:17:20 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +intl.ts:17:40 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet 16 | // toLocaleString's unlowered variants. - 17 | const envDefault = (1234.5).toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 18 | const german = (1234.5).toLocaleString("de-DE"); - - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") - -intl.ts:18:40 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet - - 17 | const envDefault = (1234.5).toLocaleString(); - 18 | const german = (1234.5).toLocaleString("de-DE"); + 17 | const german = (1234.5).toLocaleString("de-DE"); | ^~~~~~~ - 19 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); + 18 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); - hint: "en-US" (Node's default-build locale) is the one locale whose data the runtime embeds — everything else is ICU data the binary does not carry + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet -intl.ts:19:17 - error SC2020: 'Number.prototype.toLocaleString with an options bag' is part of the standard library types but has no scriptc lowering yet +intl.ts:18:17 - error SC2020: 'Number.prototype.toLocaleString with an options bag' is part of the standard library types but has no scriptc lowering yet - 18 | const german = (1234.5).toLocaleString("de-DE"); - 19 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); + 17 | const german = (1234.5).toLocaleString("de-DE"); + 18 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 20 | + 19 | - hint: the embedded data covers DEFAULT options only (decimal notation, up to 3 fraction digits, grouping) — x.toLocaleString("en-US") + hint: what lowers is the DEFAULT options shape (decimal notation, up to 3 fraction digits, grouping) at the default locale or at en-US — omit the options bag -intl.ts:22:14 - error SC2020: 'new Intl.DateTimeFormat' is part of the standard library types but has no scriptc lowering yet +intl.ts:21:14 - error SC2020: 'new Intl.DateTimeFormat' is part of the standard library types but has no scriptc lowering yet - 21 | // The rest of Intl keeps the ICU story. - 22 | const when = new Intl.DateTimeFormat("en-US").format(); + 20 | // The rest of Intl keeps the ICU story. + 21 | const when = new Intl.DateTimeFormat("en-US").format(); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 23 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); + 22 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -intl.ts:23:13 - error SC2020: 'new Intl.DurationFormat' is part of the standard library types but has no scriptc lowering yet +intl.ts:22:13 - error SC2020: 'new Intl.DurationFormat' is part of the standard library types but has no scriptc lowering yet - 22 | const when = new Intl.DateTimeFormat("en-US").format(); - 23 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); + 21 | const when = new Intl.DateTimeFormat("en-US").format(); + 22 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | const rule = new Intl.PluralRules("en-US").select(1); + 23 | const rule = new Intl.PluralRules("en-US").select(1); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -intl.ts:24:14 - error SC2020: 'new Intl.PluralRules' is part of the standard library types but has no scriptc lowering yet +intl.ts:23:14 - error SC2020: 'new Intl.PluralRules' is part of the standard library types but has no scriptc lowering yet - 23 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); - 24 | const rule = new Intl.PluralRules("en-US").select(1); + 22 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); + 23 | const rule = new Intl.PluralRules("en-US").select(1); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 25 | const canon = Intl.getCanonicalLocales("en-us"); + 24 | const canon = Intl.getCanonicalLocales("en-us"); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -intl.ts:25:15 - error SC2020: 'Intl.getCanonicalLocales' is part of the standard library types but has no scriptc lowering yet +intl.ts:24:15 - error SC2020: 'Intl.getCanonicalLocales' is part of the standard library types but has no scriptc lowering yet - 24 | const rule = new Intl.PluralRules("en-US").select(1); - 25 | const canon = Intl.getCanonicalLocales("en-us"); + 23 | const rule = new Intl.PluralRules("en-US").select(1); + 24 | const canon = Intl.getCanonicalLocales("en-us"); | ^~~~~~~~~~~~~~~~~~~~~~~~ - 26 | + 25 | - hint: locale- and ICU-backed behavior lives outside the static runtime (the localeCompare stance: code-unit order, no collation/locale data) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, plus default Unicode grapheme segmentation with Intl.Segmenter; locale negotiation, word/sentence segmentation, and resolvedOptions remain unsupported \ No newline at end of file + hint: collation and locale negotiation stay outside the static runtime (the localeCompare stance: code-unit order, no collation) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, their default-locale forms (x.toLocaleString(), d.toLocaleString(), d.toLocaleTimeString()), and Intl.Segmenter with grapheme or word granularity; sentence segmentation, resolvedOptions, and the remaining Intl constructors remain unsupported \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 6ba862c53..3daa5357e 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -32,7 +32,7 @@ stdlib-fence.ts:20:7 - error SC2020: 'NumberFormat' is part of the standard libr | ^~~ 21 | - hint: Intl formatter values have no representation — the COMPOSED en-US forms lower: new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options; the rest is ICU locale data the binary does not carry + hint: Intl formatter values have no representation — the COMPOSED forms lower: new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString() with default options, d.toLocaleString(), d.toLocaleTimeString(), and Intl.Segmenter with grapheme or word granularity; the formatter objects themselves (locale negotiation, resolvedOptions, options bags) remain unsupported stdlib-fence.ts:20:13 - error SC2020: 'new Intl.NumberFormat' is part of the standard library types but has no scriptc lowering yet @@ -77,14 +77,14 @@ stdlib-fence.ts:39:31 - error SC1090: '.split()' on a 'RegExp | string' separato | ^~~~~~~~~~~~~~~~~~~~~~~~~ 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) -stdlib-fence.ts:41:19 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:41:43 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) - 41 | const localized = (1234.5).toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ + 41 | const localized = (1234.5).toLocaleString("de-DE"); + | ^~~~~~~ 42 | // Unlowered call FORMS of lowered members. (push is fully variadic now — - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet stdlib-fence.ts:47:1 - error SC2020: '.forEach with 2 arguments' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json b/tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json new file mode 100644 index 000000000..222ff453e --- /dev/null +++ b/tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json @@ -0,0 +1 @@ +{ "plain": 6283, "san": 187569 } diff --git a/tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json b/tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json new file mode 100644 index 000000000..cf43f47fa --- /dev/null +++ b/tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json @@ -0,0 +1 @@ +{ "plain": 5069, "san": 54111 } diff --git a/tests/harness/corpus-costs/intl-word-segmenter.ts.json b/tests/harness/corpus-costs/intl-word-segmenter.ts.json new file mode 100644 index 000000000..081935b85 --- /dev/null +++ b/tests/harness/corpus-costs/intl-word-segmenter.ts.json @@ -0,0 +1 @@ +{ "plain": 5195, "san": 90586 } diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 645992cde..5096359ef 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -107,6 +107,25 @@ function wantsTransformTypes(file: string): boolean { return programInputs(file).some((f) => /\benum\s+[A-Za-z_$]/.test(readFileSync(f, "utf8"))); } +/** `// @env: NAME=VALUE[,NAME=VALUE]` in the entry file's directive head: + * the variables are set for BOTH sides — the Node oracle and the compiled + * binary — so a program can pin the ambient state that decides Intl output + * (TZ for the time zone, LC_ALL/LANG for the default locale). The directive + * lives in the program bytes the oracle cache key hashes, so a recorded + * verdict can never be replayed under different variables. */ +function programEnv(file: string): Record { + const env: Record = {}; + for (const line of directiveHead(file)) { + const m = /^\/\/ @env:\s*(.+?)\s*$/.exec(line); + if (m === null) continue; + for (const pair of m[1]!.split(",")) { + const eq = pair.indexOf("="); + if (eq > 0) env[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + } + return env; +} + /** `// @tsc-decorators` in the entry file's directive head: the Node side * runs tsc's ES-decorator downlevel of the program (target ES2022) — * decorators are the one supported construct Node cannot execute AT ALL @@ -156,6 +175,20 @@ function wantsNoDeprecation(file: string): boolean { return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l)); } +/** `// @requires-icu` in the directive head: the program's Intl forms are + * answered by ICU, which the runtime packs build only for the Linux x64 + * target in this slice. Other lanes (the macOS-native contracts, the wasm + * and Windows packs) skip these programs instead of failing: the compiler + * refuses the forms there with SC3002. */ +function requiresIcu(file: string): boolean { + return directiveHead(file).some((l) => /^\/\/ @requires-icu\s*$/.test(l)); +} + +/** Whether this machine's runtime pack can link ICU (see requiresIcu). */ +function icuAvailable(): boolean { + return process.platform === "linux" && process.arch === "x64"; +} + /** The Node oracle's argv for a corpus entry (shims + the per-program * transform flag). */ /** `// @disallow-code-generation` in the directive head: the Node oracle @@ -187,7 +220,11 @@ function nodeOracleArgs(file: string): string[] { * (readFileSync(0)), and the default open pipe would block both sides * forever — this way Node and the native binary see the same empty, * closed stream. */ -async function runBinary(cmd: string, args: string[]): Promise { +async function runBinary( + cmd: string, + args: string[], + env: Record = {}, +): Promise { // Linux fork/exec race (observed on the sandbox lane, llvm flavor): a // sibling worker's fork can inherit the freshly-linked binary's write // fd across its own spawn window, and exec answers ETXTBSY until that @@ -195,7 +232,13 @@ async function runBinary(cmd: string, args: string[]): Promise { // briefly, the npm/cargo stance. (No-op on macOS: exec there never // answers ETXTBSY.) for (let attempt = 0; ; attempt++) { - const pending = execFileAsync(cmd, args, { encoding: "buffer" }); + const pending = execFileAsync(cmd, args, { + encoding: "buffer", + // The program's `// @env:` pins layer over the inherited environment, + // so both sides see the same TZ/locale as the rest of the corpus sees + // SCRIPTC_TEST_ENV. + env: { ...process.env, ...env }, + }); pending.child.stdin?.end(); try { const { stdout, stderr } = await pending; @@ -349,7 +392,7 @@ async function runNode(file: string): Promise { // Node 24 strips types natively; the supported subset is erasable by // construction — except `// @transform-types` programs (namespaces), // which run under Node's transform mode. - const res = await runBinary("node", nodeOracleArgs(file)); + const res = await runBinary("node", nodeOracleArgs(file), programEnv(file)); if (cachePath !== null) { try { const tmp = `${cachePath}.${process.pid}.${Math.random().toString(36).slice(2)}`; @@ -396,7 +439,7 @@ async function compileAndRun(file: string): Promise { result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), ); } - return runBinary(result.binaryPath, []); + return runBinary(result.binaryPath, [], programEnv(file)); } describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized" : ""}${shardSuffix()})`, () => { @@ -412,6 +455,7 @@ describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized // `// @requires-libnode`: the binary loads Node from a libnode pack, // required in the Linux CI lanes (libnode.ts), else skipped without one. if (requiresLibnode(file) && libnodePack() === null) skip(); + if (requiresIcu(file) && !icuAvailable()) skip(); const [nodeRes, nativeRes] = await Promise.all([runNode(file), compileAndRun(file)]); // Compare as bytes; decode only for the failure diff. if (!nodeRes.stdout.equals(nativeRes.stdout)) { diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index 6ffb44b0b..28a9e90c4 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -67,16 +67,51 @@ function expectedExitCode(file: string): number { return 0; } -async function runBinary(cmd: string, args: string[], cpuLevel?: string): Promise { +/** `// @env: NAME=VALUE[,NAME=VALUE]` (differential.test.ts's twin): the + * variables are set for BOTH sides, so a program can pin the ambient state + * that decides Intl output (TZ, LC_ALL/LANG) and the oracle and the binary + * read the same one. */ +function programEnv(file: string): Record { + const env: Record = {}; + for (const line of directiveHead(file)) { + const m = /^\/\/ @env:\s*(.+?)\s*$/.exec(line); + if (m === null) continue; + for (const pair of m[1]!.split(",")) { + const eq = pair.indexOf("="); + if (eq > 0) env[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + } + return env; +} + +/** `// @requires-icu` (differential.test.ts's twin): the program's Intl + * forms are answered by ICU, which only the linux-x64 runtime pack carries + * in this slice; other lanes skip instead of hitting the refusal. */ +function requiresIcu(file: string): boolean { + return directiveHead(file).some((l) => /^\/\/ @requires-icu\s*$/.test(l)); +} + +function icuAvailable(): boolean { + return process.platform === "linux" && process.arch === "x64"; +} + +async function runBinary( + cmd: string, + args: string[], + cpuLevel?: string, + extraEnv: Record = {}, +): Promise { // Linux fork/exec race (observed on the sandbox lane): a sibling // worker's fork can inherit the freshly-linked binary's write fd across // its own spawn window, and exec answers ETXTBSY until that fd closes. // The condition is transient by construction — retry briefly, the // npm/cargo stance. for (let attempt = 0; ; attempt++) { + const env: NodeJS.ProcessEnv = { ...process.env, ...extraEnv }; + if (cpuLevel !== undefined) env["SCRIPTC_CPU_LEVEL"] = cpuLevel; const pending = execFileAsync(cmd, args, { encoding: "buffer", - ...(cpuLevel === undefined ? {} : { env: { ...process.env, SCRIPTC_CPU_LEVEL: cpuLevel } }), + ...(cpuLevel === undefined && Object.keys(extraEnv).length === 0 ? {} : { env }), }); pending.child.stdin?.end(); try { @@ -207,7 +242,13 @@ describe(`llvm differential corpus (${files.length} programs, ${optimizationMode async ([rel, file], { skip }) => { // Programs that call into Node need a libnode pack (libnode.ts). if (requiresLibnode(file) && libnodePack() === null) skip(); - const oracle = await runBinary(process.execPath, nodeOracleArgs(file)); + if (requiresIcu(file) && !icuAvailable()) skip(); + const oracle = await runBinary( + process.execPath, + nodeOracleArgs(file), + undefined, + programEnv(file), + ); const expectedExit = expectedExitCode(file); expect(oracle.exitCode).toBe(expectedExit); for (const optimization of optimizationModes) { @@ -220,7 +261,7 @@ describe(`llvm differential corpus (${files.length} programs, ${optimizationMode expect(result.backend).toBe("llvm"); expect(result.llvmPath.endsWith(".ll")).toBe(true); for (const level of cpuLevels) { - const actual = await runBinary(result.binaryPath, [], level); + const actual = await runBinary(result.binaryPath, [], level, programEnv(file)); const lane = level === undefined ? optimization : `${optimization} level ${level}`; expect(actual.stdout, `${lane} stdout`).toEqual(oracle.stdout); if (expectedExit === 0) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 337a4b595..7d30a04b8 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,7 +15,6 @@ import { promisify } from "node:util"; import { beforeEach, describe, expect, test } from "vitest"; import { analyze, compile } from "@scriptc/compiler"; import { skipUnownedTest } from "./shard.js"; -import { libnodePack } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -636,14 +635,18 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - // The package builds its segmenter at module load, a run-time fence that - // surely fires, so the package runs in its Node island and segments as - // Node does. - test("a word segmenter a package builds at load runs in its Node island", async () => { + // The package builds its word segmenter at module load. Word segmentation + // lowers to ICU now, so nothing fences: the package compiles into the + // program itself (no Node island) and splits words as Node does. The ICU + // supporting units live in the linux-x64 runtime pack only in this slice, + // so other machines check the analysis and stop before the link. + test("a word segmenter a package builds at load compiles statically", async () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); expect(coverage.diagnostics).toHaveLength(0); + expect(coverage.runtimeFences ?? []).toHaveLength(0); + if (process.platform !== "linux" || process.arch !== "x64") return; const outDir = join(cacheDir, "word-segments", sanitize ? "san" : "plain"); const result = await compile(entry, { backend: "llvm", @@ -652,11 +655,6 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { outDir, outPath: join(outDir, "program"), }); - if (libnodePack() === null) { - expect(result.ok, "no libnode pack: the compile stops").toBe(false); - if (!result.ok) expect(result.diagnostics.map((d) => d.code)).toContain("SC8005"); - return; - } if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const nativeRes = await runBinary(result.binaryPath, []); From 7cf37d9dc9203ef2803558f522f8eb82212bd3cd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:22 -0700 Subject: [PATCH 020/193] Keep ICU symbols out of non-Intl binaries in the zero-cost gate ICU builds with symbol renaming on, so its C API symbols carry the _78 version suffix, its data symbol is icudt78_dat, and its C++ symbols are Itanium manglings that name namespace icu_78. icuSymbols() classifies exactly those, and the gate's dynamic-tier check now fails a candidate that links any of them without the pattern being allowed: a program that does not lower to Intl links no ICU object at all. The engine-free Intl functions (scr_intl_num_format_en_us in scr_lib.c, scr_intl_segmenter_new in scr_grapheme.c) share the scr_intl_ prefix and are deliberately not part of the test; any scr_intl.c function that does link brings an ICU symbol with it. Co-Authored-By: Claude Opus 5.5 --- scripts/zero-cost-compare.mjs | 32 ++++++++++++ scripts/zero-cost-gate.mjs | 5 ++ tests/harness/zero-cost-icu-symbols.test.ts | 56 +++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 tests/harness/zero-cost-icu-symbols.test.ts diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 40f7287fb..accae34d8 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -393,6 +393,38 @@ export function dynamicTierSymbols(names) { * program without a call into Node.js may define any of these. */ export const NODE_ISLAND_PREFIXES = ["scr_node_island", "scr_loop_set_node_island"]; +/** The namespace ICU's own symbols live in after its version-suffixed + * renaming: `icu_78`, which Itanium mangling spells `6icu_78`. */ +export const ICU_CPP_NAMESPACE = "6icu_78"; + +/** The ICU archive: no program that answers its Intl forms without + * ICU — that is, every program whose IR has no `icu.*` libCall — may define + * a symbol from it. The test is exact because ICU builds with symbol + * renaming on: every C API symbol carries the `_78` version suffix + * (`ubrk_open_78`), its data symbol is `icudt78_dat`, and every C++ symbol + * is an Itanium mangling that names namespace `icu_78`, which the mangling + * spells `6icu_78` wherever the name occurs — as the function's own + * namespace (`_ZN6icu_78...`), in its parameter types, in vtables, typeinfo + * and guard variables, or in a libc++ instantiation over an ICU type. No + * symbol outside the archive carries either mark. + * + * scr_intl.c's own functions are deliberately not tested by name. The + * engine-free Intl paths share their prefix (`scr_intl_num_format_en_us` in + * scr_lib.c, `scr_intl_segmenter_new` in scr_grapheme.c), and they must stay + * linkable without ICU (I4). Any scr_intl.c function that does link brings an + * ICU symbol with it, which the test catches. The C++ standard library + * merged into the archive cannot leak on its own either: an archive + * contributes a member only when something references it, and only ICU's + * objects reference libc++. */ +export function icuSymbols(names) { + return [...new Set(names)] + .filter( + (name) => + name.endsWith("_78") || name.includes(ICU_CPP_NAMESPACE) || name.startsWith("icudt78"), + ) + .sort(); +} + /** The Node island symbols among `names`, sorted. */ export function nodeIslandSymbols(names) { return [...new Set(names)] diff --git a/scripts/zero-cost-gate.mjs b/scripts/zero-cost-gate.mjs index 5b5f51a21..aef7a4e60 100644 --- a/scripts/zero-cost-gate.mjs +++ b/scripts/zero-cost-gate.mjs @@ -48,6 +48,7 @@ import { linkedRuntimeFiles, llvmFunctionHashes, median, + icuSymbols, nodeIslandSymbols, nmOutput, parseNm, @@ -379,6 +380,10 @@ function dynamicTierCheck(program, baseline, candidate, artifacts, allowed) { const nodeIsland = nodeIslandSymbols(definedFunctions(candidate.exe)); if (nodeIsland.length > 0 && !allowed) failures.push(`links Node island symbols: ${nodeIsland.slice(0, 5).join(", ")}`); + // Intl over ICU is linked only into programs whose IR lowers to it. A + // program that legitimately calls it names the pattern in --allow. + const icu = icuSymbols(definedFunctions(candidate.exe)); + if (icu.length > 0 && !allowed) failures.push(`links ICU symbols: ${icu.slice(0, 5).join(", ")}`); if (typed && after.length > 0) failures.push( `fully typed program links dynamic-tier symbols: ${after.slice(0, 5).join(", ")}`, diff --git a/tests/harness/zero-cost-icu-symbols.test.ts b/tests/harness/zero-cost-icu-symbols.test.ts new file mode 100644 index 000000000..ee9fac2a1 --- /dev/null +++ b/tests/harness/zero-cost-icu-symbols.test.ts @@ -0,0 +1,56 @@ +// The symbol classifier the zero-cost gate uses to prove that a program which +// does not use Intl links no part of the ICU archive. The samples +// are the shapes `nm` printed for the archive-linked binaries: ICU's C API +// with its `_78` version suffix, its data symbol, and the several Itanium +// manglings of names in namespace icu_78 — own namespace, parameters only, +// internal linkage, guard variables, vtables and typeinfo. +import { describe, expect, it } from "vitest"; + +import { icuSymbols } from "../../scripts/zero-cost-compare.mjs"; + +const ICU_SYMBOLS = [ + "ubrk_open_78", + "unum_open_78", + "u_strToUTF8_78", + "icudt78_dat", + "_ZN6icu_7813UnicodeString6appendEPKDsi", + "_ZNK6icu_7824DateTimePatternGenerator17getDateTimeFormatE16UDateFormatStyleR10UErrorCode", + "_Z24ulocimp_toLanguageTag_78PKcRN6icu_788ByteSinkEbR10UErrorCode", + "_ZL10createPathPKciS0_iS0_RN6icu_7810CharStringEP10UErrorCode", + "_ZGVZN6icu_7823IslamicUmalquraCalendar19handleComputeFieldsEiR10UErrorCodeE14kUmalquraStart", + "_ZTWN6icu_7810CharStringE", + "_ZTVN6icu_7813UnicodeStringE", + "_ZTIN6icu_7813UnicodeStringE", + "_ZTSN6icu_7813UnicodeStringE", + // A libc++ instantiation over an ICU type exists only inside the archive. + "_ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE6appendERN6icu_7810CharStringE", +]; + +// The engine-free Intl paths share the `scr_intl_` prefix and must stay +// linkable without ICU (grapheme segmentation, the en-US number fast path), +// so the classifier does not key on it. +const NON_ICU_SYMBOLS = [ + "main", + "scr_intl_num_format_en_us", + "scr_intl_segmenter_new", + "scr_intl_segmenter_refusing", + "scr_dyn_call", + "scr_string_concat", +]; + +describe("icuSymbols", () => { + it("classifies every ICU archive symbol", () => { + expect(icuSymbols(ICU_SYMBOLS)).toEqual([...ICU_SYMBOLS].sort()); + }); + + it("leaves the engine-free runtime alone", () => { + expect(icuSymbols(NON_ICU_SYMBOLS)).toEqual([]); + }); + + it("deduplicates and sorts", () => { + expect(icuSymbols(["ubrk_close_78", "main", "ubrk_close_78", "icudt78_dat"])).toEqual([ + "icudt78_dat", + "ubrk_close_78", + ]); + }); +}); From 7a257160bb5e9ea9efd97916a4840ffbdb22f266 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:45:52 -0700 Subject: [PATCH 021/193] Pin the accumulated dynamic handle tags NODE_ISLAND and the twelve WS_* stream handles lived in SCR_DYNH_NEW_TAGS, whose rows are numbered positionally from the largest pinned value. Adding SEGMENTER_WORD and SEGMENTS_WORD ahead of them renumbered every WS_* tag by two, and those numbers are immediate operands in the link-gated stream units (scr_webstreams.c, scr_stream.c, scr_stream_from_web.c, scr_fetch.c): eight runtime objects changed and 35 non-Intl corpus programs stopped being byte-identical in the zero-cost gate. Move the thirteen older rows to SCR_DYNH_TAGS with the values they already had (84 through 96). Their numbers, the enum, and every unit's object code return to the base build, and the two Intl tags number from 97. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_dyn.h | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 4047ad307..c46b5dc9c 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -207,6 +207,7 @@ typedef enum { X(NET_SERVER, 3) /* ScrNetServer — net.Server (http.Server rides the same handle) */ \ X(NET_SOCKET, 2) /* ScrNetSocket — net.Socket */ \ X(NET_SOCKET_ADDRESS, 47) /* net.SocketAddress (scr_net.c) */ \ + X(NODE_ISLAND, 84) /* a Node island value (scr_node_island_handle.c) */ \ X(PRIVATE_KEY, 65) /* node:crypto PrivateKeyObject (scr_pki.c) */ \ X(PUBLIC_KEY, 66) /* node:crypto PublicKeyObject (scr_pki.c) */ \ X(REGEXP, 26) /* native RegExp, shared compiled pattern */ \ @@ -236,25 +237,24 @@ typedef enum { X(WEB_TRANSFORM, 73) /* native identity TransformStream (scr_fetch.c) */ \ X(WEB_WRITABLE, 74) /* a TransformStream's WritableStream (scr_fetch.c) */ \ X(WEB_WRITER, 75) /* WritableStreamDefaultWriter (scr_fetch.c) */ \ - X(WORKER, 35) + X(WORKER, 35) \ + X(WS_BYTE_STRATEGY, 85) /* ByteLengthQueuingStrategy (scr_webstreams.c) */ \ + X(WS_COUNT_STRATEGY, 86) /* CountQueuingStrategy (scr_webstreams.c) */ \ + X(WS_RCONTROLLER, 87) /* ReadableStreamDefaultController (scr_webstreams.c) */ \ + X(WS_READABLE, 88) /* ReadableStream for checked-dynamic code (scr_webstreams.c) */ \ + X(WS_READER, 89) /* ReadableStreamDefaultReader (scr_webstreams.c) */ \ + X(WS_TCONTROLLER, 90) /* TransformStreamDefaultController (scr_webstreams.c) */ \ + X(WS_TEXT_DECODER, 91) /* TextDecoderStream (scr_webstreams.c) */ \ + X(WS_TEXT_ENCODER, 92) /* TextEncoderStream (scr_webstreams.c) */ \ + X(WS_TRANSFORM, 93) /* TransformStream for checked-dynamic code (scr_webstreams.c) */ \ + X(WS_WCONTROLLER, 94) /* WritableStreamDefaultController (scr_webstreams.c) */ \ + X(WS_WRITABLE, 95) /* WritableStream for checked-dynamic code (scr_webstreams.c) */ \ + X(WS_WRITER, 96) /* WritableStreamDefaultWriter (scr_webstreams.c) */ /* Tags added since the last pin, by name only (see above). */ #define SCR_DYNH_NEW_TAGS(Y) \ - Y(NODE_ISLAND) /* a Node island value (scr_node_island_handle.c) */ \ Y(SEGMENTER_WORD) /* ICU word-granularity Segmenter (scr_intl.c) */ \ - Y(SEGMENTS_WORD) /* its word segmentation result (scr_intl.c) */ \ - Y(WS_BYTE_STRATEGY) /* ByteLengthQueuingStrategy (scr_webstreams.c) */ \ - Y(WS_COUNT_STRATEGY) /* CountQueuingStrategy (scr_webstreams.c) */ \ - Y(WS_RCONTROLLER) /* ReadableStreamDefaultController (scr_webstreams.c) */ \ - Y(WS_READABLE) /* ReadableStream for checked-dynamic code (scr_webstreams.c) */ \ - Y(WS_READER) /* ReadableStreamDefaultReader (scr_webstreams.c) */ \ - Y(WS_TCONTROLLER) /* TransformStreamDefaultController (scr_webstreams.c) */ \ - Y(WS_TEXT_DECODER) /* TextDecoderStream (scr_webstreams.c) */ \ - Y(WS_TEXT_ENCODER) /* TextEncoderStream (scr_webstreams.c) */ \ - Y(WS_TRANSFORM) /* TransformStream for checked-dynamic code (scr_webstreams.c) */ \ - Y(WS_WCONTROLLER) /* WritableStreamDefaultController (scr_webstreams.c) */ \ - Y(WS_WRITABLE) /* WritableStream for checked-dynamic code (scr_webstreams.c) */ \ - Y(WS_WRITER) /* WritableStreamDefaultWriter (scr_webstreams.c) */ + Y(SEGMENTS_WORD) /* its word segmentation result (scr_intl.c) */ /* Values below the largest pinned one that no tag holds, each with the * reason. Never reuse one without removing it here. */ From 93765cd6a2b6439f8447959abdec6a8573c393d6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:15:56 -0700 Subject: [PATCH 022/193] Pass enclosing-frame values to generic arrow instances as trailing parameters A generic arrow or function-expression binding declared inside a function used to be refused (SC1090) whenever its body read anything from the enclosing frame, because its monomorphized instances are module functions with no closure. Give each instance the frame it needs: every value the binding reads from the enclosing function becomes a TRAILING parameter of the instance, and every call site (which sits in the declaring frame or in a closure over it) passes the current value after the declared arguments. The instance body binds the same symbol, so it resolves the reference like any parameter. Sound only while the original binding never changes, so the fence now covers exactly what a copy cannot stand in for: `this`, `super`, `new.target`, `arguments`, an enclosing type parameter, an enclosing generic scope (per-instantiation types), and any binding with a write anywhere in the file or declared after the binding (temporal dead zone). Corpus: withClientIdentity/withIdentity, physicalModels (captured Set), listTwo (captured local function), runTracked (async, call from a nested closure) and twice (two instances, different capture values). The generic-value-bindings diagnostics fixture now pins the fences that remain (a capture the file reassigns, and an enclosing generic scope) instead of the never-reassigned parameter it used to. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 5b23e6ccf110c66e96fad472265d7b53db0f342a) --- .../frontend/lowering/generic-functions.ts | 162 +++++++++++++++++- .../generic-arrow-enclosing-captures.ts.json | 6 + .../generic-arrow-enclosing-captures.ts | 74 ++++++++ tests/diagnostics/generic-value-bindings.ts | 18 +- .../generic-value-bindings.ts.txt | 66 ++++--- .../generic-arrow-enclosing-captures.ts.json | 1 + 6 files changed, 290 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-enclosing-captures.ts.json create mode 100644 tests/corpus/generic-arrow-enclosing-captures.ts create mode 100644 tests/harness/corpus-costs/generic-arrow-enclosing-captures.ts.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 046b34008..8397bbc0a 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -101,6 +101,14 @@ export interface GenericFnInfo { * declaration makes a new function object in JavaScript, while the * compiled instances are shared module functions, so only calls lower. */ functionScoped?: true; + /** The enclosing-frame values a function-scoped generic arrow reads. + * The instances are module functions, so each captured binding travels + * as a TRAILING parameter: the call sites (which sit in the declaring + * frame or in a closure over it) pass the value, in this order, and the + * instance body reads it through the same symbol. Only never-reassigned + * bindings qualify — a shared copy is exact exactly as long as nothing + * writes the original. */ + captures?: FrameCapture[]; /** The declaration of a generic arrow/function-expression binding. */ bindingDecl?: ts.VariableDeclaration; /** A module-scope binding that code can use before its declaration @@ -131,6 +139,17 @@ export interface GenericFnInfo { nameReadParams?: Map; } +/** One enclosing-frame value a generic arrow/function-expression binding + * reads (GenericFnInfo.captures). `nameNode` is the binding's own + * declaration identifier: declaring a local for it binds the SYMBOL, so + * the instance body resolves the reference like any parameter. */ +export interface FrameCapture { + symbol: ts.Symbol; + nameNode: ts.Node; + name: string; + type: IrType; +} + export interface GenericInstance { path: InstantiationPath; name: string; @@ -285,6 +304,11 @@ export function lowerGenericCall( ? implicitCallInstance(lowerer, expr, info) : genericCallInstance(lowerer, expr, info); const args = lowerer.completeArgs(expr.arguments.nodes, instance.params, loc, expr); + // The enclosing-frame values follow the declared arguments, the order + // the instance takes them in (lowerGenericInstance). + for (const capture of info.captures ?? []) { + args.push(lowerer.lowerExprExpecting(capture.nameNode as ts.Expression, capture.type)); + } const call: IrExpr & { kind: "call" } = { kind: "call", callee: instance.name, @@ -1250,6 +1274,14 @@ export function lowerGenericInstance( // threaded — a default mentioning T resolves like any body expression. const declared = lowerer.declareParams(decl.parameters.nodes, inst.params); params.push(...declared.params); + // The enclosing-frame values travel as TRAILING parameters, after the + // declared ones: a call lowers its arguments first (JavaScript's + // order), then reads the captures. Binding each local to the captured + // symbol is what lets the body resolve the reference. + for (const capture of info.captures ?? []) { + const local = lowerer.declareLocal(capture.nameNode, capture.name, capture.type, false); + params.push({ localId: local.id, name: capture.name, type: capture.type }); + } const body = [...receiverInit, ...declared.prologue]; const bodyBlock = blockBodyOf(decl); if (bodyBlock) { @@ -2638,6 +2670,118 @@ function capturesEnclosingFrame( return captures; } +/** The enclosing-frame VALUES `fn`, bound by `decl`, reads — in first-use + * order, the order the instances take them as trailing parameters. The + * shape is the one the instance body resolves: a binding declared by an + * enclosing function (a local, a parameter, a local function or class). + * + * Module-scope declarations and the binding itself are reachable from the + * module functions the instances compile to, so they are not captures; nor + * are type-only declarations, whose mapping is static. `this`, `super`, + * `arguments`, `new.target`, and an enclosing generic's type parameter (or + * any binding the closure could observe a LATER write of) have no value a + * parameter can stand in for and stay fenced. */ +function enclosingFrameCaptures( + lowerer: Lowerer, + fn: ts.FunctionExpression | ts.ArrowFunction, + decl: ts.VariableDeclaration, + scope: ts.Node, + name: string, +): FrameCapture[] { + const file = ts.getSourceFileOfNode(decl); + const self = lowerer.checker.getSymbolAtLocation(decl.name); + const frameFence = (): never => + lowerer.unsupported( + "SC1090", + fn, + `generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of '${name}' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope)`, + ); + const captures: FrameCapture[] = []; + const seen = new Set(); + const scan = (n: ts.Node): void => { + if ( + n.kind === ts.SyntaxKind.ThisKeyword || + n.kind === ts.SyntaxKind.SuperKeyword || + ts.isMetaProperty(n) + ) { + // Arrow bodies see the ENCLOSING this; function expressions + // rebind their own — but a bare `this` there is untyped JS + // dynamism either way. Reject both, cheaply and soundly. + frameFence(); + } + if (ts.isIdentifier(n)) { + const s = lowerer.checker.getSymbolAtLocation(n); + if (s === undefined) { + if (n.text === "arguments") frameFence(); + } else if (s !== self && !seen.has(s)) { + for (const d of s.declarations) { + if (ts.getSourceFileOfNode(d) !== file || (d.pos >= fn.pos && d.end <= fn.end)) continue; + let enclosing = false; + for ( + let p: ts.Node | undefined = d.parent; + p !== undefined && !ts.isSourceFile(p); + p = p.parent + ) { + if (ts.isFunctionLike(p)) { + enclosing = true; + break; + } + } + if (!enclosing) continue; + // A type parameter (of the enclosing function or class) keys + // the enclosing instantiation; the instance would need that + // frame's type arguments, which this binding cannot carry. + if ((s.flags & ts.SymbolFlags.TypeParameter) !== 0) frameFence(); + // Type-only declarations cost no runtime slot. + if ((s.flags & ts.SymbolFlags.Value) === 0) break; + // The instance holds a COPY of the value, exact only while the + // original never changes: a write anywhere in the file could + // make the closure observe a different binding state later. + if (!bindingNeverReassigned(lowerer, s, d)) frameFence(); + // ...and exact only once the original is INITIALIZED. A capture + // declared after the binding could be read from a call that runs + // before its initializer — a temporal dead zone JavaScript would + // throw on. Declared before, the copy is always the initialized + // value: reaching the binding's own declaration ran it. + if (d.pos > decl.pos) frameFence(); + // The declaration's own identifier (a binding element's for a + // destructured name): lowering it resolves the SYMBOL, so the + // instance body reads the parameter we declare for it. + const declaredName = (d as { name?: ts.Node }).name; + const nameNode = + declaredName !== undefined && ts.isIdentifier(declaredName) ? declaredName : d; + const type = lowerer.irTypeOf(nameNode); + captures.push({ + symbol: s, + nameNode, + name: ts.isIdentifier(nameNode) ? nameNode.text : ts.symbolName(s), + type, + }); + seen.add(s); + break; + } + } + } + ts.forEachChild(n, scan); + }; + scan(scope); + // A capture's type is fixed when the info is registered, once per + // binding, while an enclosing generic function or class re-lowers its + // frame per instantiation: the second instantiation's value would have a + // different type than the parameter the shared instance declares. + // Generic enclosing scopes keep the fence; non-generic ones carry no + // type argument for the instance to miss. + if (captures.length > 0) { + for (let p: ts.Node | undefined = fn.parent; p && !ts.isSourceFile(p); p = p.parent) { + if (!ts.isFunctionLike(p) && !ts.isClassLikeDeclaration(p)) continue; + const typeParameters = (p as { typeParameters?: ts.NodeArray }) + .typeParameters; + if (typeParameters !== undefined && typeParameters.nodes.length > 0) frameFence(); + } + } + return captures; +} + /** Registers (or returns) the GenericFnInfo of a qualifying implicit-any * function-value binding — implicitLocalFnNodeOf's companion, the * bindingGenericFnInfoOf shape: the info enters genericFnsBySymbol under @@ -2848,22 +2992,21 @@ export function bindingGenericFnInfoOf( if (existing) return existing; const name = (decl.name as ts.Identifier).text; if (fnNode.asteriskToken) lowerer.unsupported("SC1071", fnNode); - // Inside a function, the compiled instances are module functions, so - // the initializer may not use the enclosing frame. + // Inside a function, the compiled instances are module functions: the + // values the initializer reads from the enclosing frame travel as + // trailing parameters (FunctionCapture). `this`, `arguments` and an + // enclosing generic's type parameters have no value to pass and stay + // fenced. let functionScoped = false; + let captures: FrameCapture[] | undefined; for ( let n: ts.Node | undefined = decl.parent; n !== undefined && !ts.isSourceFile(n); n = n.parent ) { if (ts.isFunctionLike(n)) { - if (capturesEnclosingFrame(lowerer, fnNode, decl, fnNode)) { - lowerer.unsupported( - "SC1090", - fnNode, - `generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of '${name}' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope)`, - ); - } + const found = enclosingFrameCaptures(lowerer, fnNode, decl, fnNode, name); + if (found.length > 0) captures = found; functionScoped = true; break; } @@ -2912,6 +3055,7 @@ export function bindingGenericFnInfoOf( typeParams, instances: new Map(), ...(functionScoped ? { functionScoped: true as const } : {}), + ...(captures ? { captures } : {}), bindingDecl: decl, }; lowerer.bindingGenericFns.set(fnNode, info); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-enclosing-captures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-enclosing-captures.ts.json new file mode 100644 index 000000000..056ab519d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-enclosing-captures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-arrow-enclosing-captures.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-arrow-enclosing-captures.ts b/tests/corpus/generic-arrow-enclosing-captures.ts new file mode 100644 index 000000000..3dac369da --- /dev/null +++ b/tests/corpus/generic-arrow-enclosing-captures.ts @@ -0,0 +1,74 @@ +// A generic arrow or function-expression binding declared inside a function +// may capture that function's values. Each monomorphized instance is still a +// module function: every captured binding becomes a leading parameter and the +// call sites pass the current value, so the instance reads what JavaScript's +// closure would read. +// +// The capture may be a parameter, a local, a local function declaration, or a +// binding read through a nested closure created around the call site. + +interface ClientIdentity { + id: string; + scope?: string; +} + +function mergeClientIdentity(base: ClientIdentity, other?: ClientIdentity): ClientIdentity { + return other === undefined ? base : { id: other.id, scope: other.scope ?? base.scope }; +} + +function withClientIdentity(identity: ClientIdentity, describe: (o: ClientIdentity | undefined) => string): string { + const withIdentity = (options: T | undefined): ClientIdentity => + mergeClientIdentity(identity, options?.identity); + return describe(withIdentity<{ identity?: ClientIdentity }>({ identity: undefined })); +} + +console.log(withClientIdentity({ id: "base", scope: "read" }, (o) => `${o?.id}/${o?.scope}`)); +console.log(withClientIdentity({ id: "base" }, (o) => (o === undefined ? "none" : `${o.id}/${o.scope ?? "-"}`))); + +// A captured local Set and an enclosing parameter, read from a narrower +// callback inside the generic body. +function physicalModels(models: { id: string }[], virtual: { id: string }[]): string[] { + const ids = new Set(virtual.map((model) => model.id)); + const physical = (list: readonly T[]): T[] => + list.filter((model) => !model.id.startsWith("virtual:") && !ids.has(model.id)); + return physical(models).map((model) => model.id); +} + +console.log(physicalModels([{ id: "a" }, { id: "virt" }, { id: "virtual:b" }], [{ id: "virt" }])); + +// The captured binding is a local function declaration, and the generic body +// calls it. +function listTwo(values: string[], key: string): string[] { + const label = (value: string): string => `${key}:${value}`; + const list = (items: readonly T[]): string[] => items.map((item) => label(item)); + return list(values); +} + +console.log(listTwo(["x", "y"], "k").join(",")); + +// An async generic arrow capturing a local Set; the call sits in a nested +// closure declared after the capture. +async function runTracked(work: Promise): Promise { + const trackedIds = new Set(); + const track = (id: number, item: Promise): Promise => { + trackedIds.add(id); + return item; + }; + const wrapped = (): Promise => track(1, work); + const value = await wrapped(); + console.log("tracked", trackedIds.size, trackedIds.has(1), value); + return value; +} + +void runTracked(Promise.resolve(7)); + +// Two instances of one generic arrow over different signatures share the +// capture plumbing; the capture value differs per enclosing call. +function twice(offset: number, value: string): string { + const add = (item: T): T | string => + (typeof item === "number" ? item + offset : `${item}#${offset}`) as T | string; + return `${add(value)}|${add(1)}`; +} + +console.log(twice(10, "v")); +console.log(twice(20, "w")); diff --git a/tests/diagnostics/generic-value-bindings.ts b/tests/diagnostics/generic-value-bindings.ts index 7c786c67c..18e41ee77 100644 --- a/tests/diagnostics/generic-value-bindings.ts +++ b/tests/diagnostics/generic-value-bindings.ts @@ -19,14 +19,26 @@ console.log(typeof stored); const flipAlias = flip; console.log(flipAlias(5)); -// Declared inside a function and reading its parameter: instances are -// module functions and cannot capture the enclosing frame. +// Declared inside a function and reading a binding that is written after +// the binding: the instance would hold a copy, while the closure reads +// whatever the write left behind. (A binding that is never reassigned is +// supported — it travels as a trailing parameter.) function outer(base: number): number { - const inner = (x: T): [T, number] => [x, base]; + let scale = base; + const inner = (x: T): [T, number] => [x, scale]; + scale += 1; return inner(3)[1]; } console.log(outer(4)); +// Declared inside a GENERIC function: the enclosing frame re-lowers per +// instantiation, so the shared instance cannot carry its type argument. +function outerGeneric(seed: U): string { + const inner = (x: T): string => `${String(x)}${String(seed)}`; + return inner(3); +} +console.log(outerGeneric(1)); + // Used as a value inside a function: each call of the enclosing function // makes a new function object (`make() !== make()`), and the compiled // instances are shared module functions. diff --git a/tests/harness/__snapshots__/generic-value-bindings.ts.txt b/tests/harness/__snapshots__/generic-value-bindings.ts.txt index 835b84fa7..28c1e9bc6 100644 --- a/tests/harness/__snapshots__/generic-value-bindings.ts.txt +++ b/tests/harness/__snapshots__/generic-value-bindings.ts.txt @@ -57,46 +57,62 @@ generic-value-bindings.ts:20:13 - error SC2004: uses of 'flipAlias' inherit the hint: the declaration of 'flipAlias' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:25:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet +generic-value-bindings.ts:28:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet - 24 | function outer(base: number): number { - 25 | const inner = (x: T): [T, number] => [x, base]; - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 26 | return inner(3)[1]; + 27 | let scale = base; + 28 | const inner = (x: T): [T, number] => [x, scale]; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 29 | scale += 1; -generic-value-bindings.ts:26:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration +generic-value-bindings.ts:30:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration - 25 | const inner = (x: T): [T, number] => [x, base]; - 26 | return inner(3)[1]; + 29 | scale += 1; + 30 | return inner(3)[1]; | ^~~~~ - 27 | } + 31 | } hint: the declaration of 'inner' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:35:10 - error SC1090: generic arrow/function-expression bindings declared inside functions as values (each call of the enclosing function makes a new 'same' function object, while its compiled instances are shared module functions — call 'same' directly, or declare it at module scope) are not supported yet +generic-value-bindings.ts:37:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet (instantiating 'outerGeneric' with ) - 34 | const same = (x: T): T => x; - 35 | return same; + 36 | function outerGeneric(seed: U): string { + 37 | const inner = (x: T): string => `${String(x)}${String(seed)}`; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 38 | return inner(3); + +generic-value-bindings.ts:38:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration (instantiating 'outerGeneric' with ) + + 37 | const inner = (x: T): string => `${String(x)}${String(seed)}`; + 38 | return inner(3); + | ^~~~~ + 39 | } + + hint: the declaration of 'inner' did not compile — fix the diagnostic reported there and these sites clear with it + +generic-value-bindings.ts:47:10 - error SC1090: generic arrow/function-expression bindings declared inside functions as values (each call of the enclosing function makes a new 'same' function object, while its compiled instances are shared module functions — call 'same' directly, or declare it at module scope) are not supported yet + + 46 | const same = (x: T): T => x; + 47 | return same; | ^~~~~~~~~~~~ - 36 | } + 48 | } -generic-value-bindings.ts:48:13 - error SC1090: results that are themselves generic functions ('(y: U) => string' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments) are not supported yet +generic-value-bindings.ts:60:13 - error SC1090: results that are themselves generic functions ('(y: U) => string' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments) are not supported yet - 47 | const curried = (x: T) => (y: U): string => `${x}|${y}`; - 48 | console.log(curried(1)("a")); + 59 | const curried = (x: T) => (y: U): string => `${x}|${y}`; + 60 | console.log(curried(1)("a")); | ^~~~~~~~~~ - 49 | + 61 | -generic-value-bindings.ts:60:15 - error SC1090: calls of the generic method 'm' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize) is not supported yet +generic-value-bindings.ts:72:15 - error SC1090: calls of the generic method 'm' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize) is not supported yet - 59 | function throughParam(a: Amb): void { - 60 | console.log(a.m(4)); + 71 | function throughParam(a: Amb): void { + 72 | console.log(a.m(4)); | ^~~~~~ - 61 | } + 73 | } -generic-value-bindings.ts:71:32 - error SC1090: generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding 'picked' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses) are not supported yet +generic-value-bindings.ts:83:32 - error SC1090: generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding 'picked' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses) are not supported yet - 70 | } - 71 | const invoke = (): number => picked(1); + 82 | } + 83 | const invoke = (): number => picked(1); | ^~~~~~ - 72 | return invoke(); \ No newline at end of file + 84 | return invoke(); \ No newline at end of file diff --git a/tests/harness/corpus-costs/generic-arrow-enclosing-captures.ts.json b/tests/harness/corpus-costs/generic-arrow-enclosing-captures.ts.json new file mode 100644 index 000000000..5f3157836 --- /dev/null +++ b/tests/harness/corpus-costs/generic-arrow-enclosing-captures.ts.json @@ -0,0 +1 @@ +{ "plain": 5849, "san": 4152 } From 5f169ed6fe49040fac26cc104c1ce856e3f0df80 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:16:01 -0700 Subject: [PATCH 023/193] Complete function-value arguments in optional calls The `f?.()` branch of the optional-chain lowering forwarded a spread argument list through completeFuncValueArgs but mapped a plain argument list itself, so an omitted optional parameter was never padded with the callee's undefined arm. The lib-boundary arity fence then refused the call ("2 arguments where its lowered signature takes 3"), which is what `callbacks?.onRetryFinished?.(false, attempt)` hit: an unguarded call completes its arguments, a guarded one did not. Run both spellings through the same completion. It evaluates inside the guard, so a nullish callee still evaluates no argument, and a typed rest now packs from the guarded call too. Corpus: optional-chained callbacks with a trailing optional parameter and with a typed rest, through a present, absent, and undefined receiver. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f574073e2392ec24cd2cfca61651759d3359e603) --- .../lowering/expressions/optional-chains.ts | 33 +++++------ .../function-value-arity-completion.ts.json | 6 ++ .../corpus/function-value-arity-completion.ts | 57 +++++++++++++++++++ .../function-value-arity-completion.ts.json | 1 + 4 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json create mode 100644 tests/corpus/function-value-arity-completion.ts create mode 100644 tests/harness/corpus-costs/function-value-arity-completion.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 104d85a80..717fd8c66 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -1,5 +1,4 @@ import { isNavigatorGlobal } from "../builtins/navigator.js"; -import { isSafeToDiscard } from "./evaluation-safety.js"; import { completeFuncValueArgs } from "../call-arguments.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; @@ -449,24 +448,20 @@ export function lowerOptionalChain( // directly (no member dispatch exists to re-enter). if (dotNode === expr && ts.isCallExpression(expr)) { if (narrowed.kind !== "func") lowerer.badType(recvNode, lowerer.typeOf(recvNode)); - const params = narrowed.params; - // Forwarded fixed tuples (`f?.(...args)`) complete into the slots. - if (expr.arguments.nodes.some(ts.isSpreadElement)) { - const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc); - const call: IrExpr = { - kind: "callValue", - callee: recvRef, - args: completed, - type: narrowed.ret, - loc, - }; - return lowerer.finishOptionalChain(expr, id, receiver, call, loc); - } - const args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, params[i])); - // Effect-free surplus arguments (a dropped `void` slot) are ignored. - while (narrowed.rest !== true && args.length > params.length && isSafeToDiscard(args.at(-1)!)) - args.pop(); - const body: IrExpr = { kind: "callValue", callee: recvRef, args, type: narrowed.ret, loc }; + // The same completed function-value ABI an unguarded call uses: an + // omitted optional argument still reaches the callee as undefined + // (`cb?.onRetryFinished?.(false, attempt)` must see its third + // parameter), a forwarded tuple completes into the slots, and an + // effect-free surplus argument is ignored. The completion runs INSIDE + // the guard, so a nullish callee still evaluates no argument. + const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc); + const body: IrExpr = { + kind: "callValue", + callee: recvRef, + args: completed, + type: narrowed.ret, + loc, + }; return lowerer.finishOptionalChain(expr, id, receiver, body, loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json new file mode 100644 index 000000000..5f4eb9487 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-value-arity-completion.ts" + ], + "diags": [] +} diff --git a/tests/corpus/function-value-arity-completion.ts b/tests/corpus/function-value-arity-completion.ts new file mode 100644 index 000000000..b2dddaa06 --- /dev/null +++ b/tests/corpus/function-value-arity-completion.ts @@ -0,0 +1,57 @@ +// Calling a function value short is legal TypeScript whenever the omitted +// suffix is optional: the native callee still receives every parameter, so +// the call completes the missing slots with undefined exactly as the JS call +// would. +// +// The same holds for a call through an optional-chained property +// (`cb?.onRetryFinished?.(false, attempt)`), which evaluates the callee and +// its arguments once, in order. + +interface RetryCallbacks { + onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void; + onAttempt?: (attempt: number, ...notes: string[]) => string; +} + +function finish(callbacks: RetryCallbacks | undefined, ok: boolean, attempt: number): string[] { + const log: string[] = []; + const sink: RetryCallbacks = { + onRetryFinished: (success, n, finalError) => { + log.push(`finished ${success ? "ok" : "no"} ${n} ${finalError === undefined ? "none" : finalError}`); + }, + onAttempt: (n, ...notes) => `${n}:${notes.join("+")}`, + }; + sink.onRetryFinished?.(ok, attempt); + sink.onRetryFinished?.(ok, attempt, "boom"); + callbacks?.onRetryFinished?.(ok, attempt + 1); + callbacks?.onAttempt?.(attempt, "a", "b"); + return log; +} + +console.log(finish(undefined, true, 1).join("; ")); +console.log( + finish( + { + onRetryFinished: (success, attempt, finalError) => { + console.log(`callback ${success} ${attempt} ${finalError ?? "-"}`); + }, + onAttempt: (attempt, ...notes) => { + console.log(`attempt ${attempt} ${notes.length}`); + return "ignored"; + }, + }, + false, + 2, + ).join("; "), +); + +// An omitted trailing argument for a callback whose parameter is optional in +// the middle of a longer signature. +type Reporter = (message: string, code?: number, detail?: string) => string; + +function report(to: Reporter): void { + console.log(to("first")); + console.log(to("second", 42)); + console.log(to("third", undefined, "why")); +} + +report((message, code, detail) => `${message}/${code ?? 0}/${detail ?? "-"}`); diff --git a/tests/harness/corpus-costs/function-value-arity-completion.ts.json b/tests/harness/corpus-costs/function-value-arity-completion.ts.json new file mode 100644 index 000000000..23a63e608 --- /dev/null +++ b/tests/harness/corpus-costs/function-value-arity-completion.ts.json @@ -0,0 +1 @@ +{ "plain": 5094, "san": 6883 } From 1846753a3c8bd6389f031e2c97dfe0f9e3870bd5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:16:06 -0700 Subject: [PATCH 024/193] Pin declared-arity narrowing as a loud refusal, and the accepted shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A function value's declared type may name fewer parameters than the function it holds, and a later widening call then passes arguments the declared type never spelled. JavaScript's function object carries all of its parameters, so nothing may be dropped on that path — that would be a silent divergence from Node. The narrowed store is already a named refusal (a compiled function value is one concrete native ABI: "'(string, number) => number' values where '(string) => number' is expected"), reported at the narrowing site for a binding and for an object-literal member slot. Record both in the diagnostics snapshot so the refusal cannot regress into a silent drop. Pin the shapes that DO keep every argument: a class member whose overloads spell fewer parameters than its implementation, the member read as a value (the read keeps the implementation's JS arity), and a value that entered through an untyped slot and is widened back. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 94d44e947c48dc0184c941d526380ebd1b1b443d) --- .../function-value-declared-arity.ts.json | 6 ++++ ...ion-value-declared-arity-narrowing.ts.json | 6 ++++ tests/corpus/function-value-declared-arity.ts | 31 +++++++++++++++++++ ...function-value-declared-arity-narrowing.ts | 25 +++++++++++++++ ...tion-value-declared-arity-narrowing.ts.txt | 13 ++++++++ .../function-value-declared-arity.ts.json | 1 + 6 files changed, 82 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-declared-arity.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-declared-arity-narrowing.ts.json create mode 100644 tests/corpus/function-value-declared-arity.ts create mode 100644 tests/diagnostics/function-value-declared-arity-narrowing.ts create mode 100644 tests/harness/__snapshots__/function-value-declared-arity-narrowing.ts.txt create mode 100644 tests/harness/corpus-costs/function-value-declared-arity.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-declared-arity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-declared-arity.ts.json new file mode 100644 index 000000000..630c6a4e3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-declared-arity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-value-declared-arity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-declared-arity-narrowing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-declared-arity-narrowing.ts.json new file mode 100644 index 000000000..ecea4a8c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-declared-arity-narrowing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/function-value-declared-arity-narrowing.ts" + ], + "diags": [] +} diff --git a/tests/corpus/function-value-declared-arity.ts b/tests/corpus/function-value-declared-arity.ts new file mode 100644 index 000000000..e9eeb529b --- /dev/null +++ b/tests/corpus/function-value-declared-arity.ts @@ -0,0 +1,31 @@ +// A function holds ONE implementation, and a declared type only constrains +// call sites. An overloaded member's implementation may declare more +// parameters than any single overload spells; every overload's arguments +// still reach it — and so do the arguments of a later WIDENED call, because +// the value read out of the member keeps the implementation's full arity +// (JavaScript has no arity check; a missing argument is just undefined). + +class Writer { + write(text: string): number; + write(text: string, times: number): number; + write(text: string, times?: number): number { + return text.length * (times === undefined ? 1 : times); + } +} + +const writer = new Writer(); +console.log(writer.write("abc")); +console.log(writer.write("abcd", 3)); + +// The member read as a value carries the same one implementation, so the +// two-argument call reaches its second parameter. +const writeValue = writer.write; +console.log(writeValue("abc")); +console.log(writeValue("abcd", 3)); + +// A value that entered through an untyped slot keeps the arity it was +// created with; widening it back delivers every argument. +const loose: any = (text: string, times: number) => text.length * times; +const widened = loose as (text: string, times: number) => number; +console.log(widened("abcd", 3)); +console.log(widened("xy", 10)); diff --git a/tests/diagnostics/function-value-declared-arity-narrowing.ts b/tests/diagnostics/function-value-declared-arity-narrowing.ts new file mode 100644 index 000000000..922b2eb51 --- /dev/null +++ b/tests/diagnostics/function-value-declared-arity-narrowing.ts @@ -0,0 +1,25 @@ +// Declared-arity narrowing: a function value stored under a type that names +// FEWER parameters than the value's own signature. Node's function object +// still carries every parameter, so a later widened call would pass all of +// its arguments (the surplus must not be dropped); a compiled function value +// is one concrete native ABI, so the narrowing store is refused at every +// observable use instead. +const impl = (text: string, times: number) => text.length * times; + +// (1) Narrowing at a binding, then calling through the narrow type. +const narrowed = impl as unknown as (text: string) => number; +console.log(narrowed("abc")); + +// (2) The same narrowing in an object literal's member slot, with the member +// read back and widened to the implementation's arity. +interface Sink { + write(text: string): number; +} + +function makeWriter(): Sink { + return { write: impl as unknown as (text: string) => number }; +} + +const sink = makeWriter(); +const widened = sink.write as (text: string, times: number) => number; +console.log(widened("abcd", 3)); diff --git a/tests/harness/__snapshots__/function-value-declared-arity-narrowing.ts.txt b/tests/harness/__snapshots__/function-value-declared-arity-narrowing.ts.txt new file mode 100644 index 000000000..e2163063f --- /dev/null +++ b/tests/harness/__snapshots__/function-value-declared-arity-narrowing.ts.txt @@ -0,0 +1,13 @@ +function-value-declared-arity-narrowing.ts:10:18 - error SC1090: '(string, number) => number' values where '(string) => number' is expected is not supported yet + + 9 | // (1) Narrowing at a binding, then calling through the narrow type. + 10 | const narrowed = impl as unknown as (text: string) => number; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 11 | console.log(narrowed("abc")); + +function-value-declared-arity-narrowing.ts:20:19 - error SC1090: '(string, number) => number' values where '(string) => number' is expected is not supported yet + + 19 | function makeWriter(): Sink { + 20 | return { write: impl as unknown as (text: string) => number }; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 21 | } \ No newline at end of file diff --git a/tests/harness/corpus-costs/function-value-declared-arity.ts.json b/tests/harness/corpus-costs/function-value-declared-arity.ts.json new file mode 100644 index 000000000..b99ff1c44 --- /dev/null +++ b/tests/harness/corpus-costs/function-value-declared-arity.ts.json @@ -0,0 +1 @@ +{ "plain": 5138, "san": 7006 } From 3e2b4ab25b88c027ba18ec795a9b9938a3f84c85 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:59:19 -0700 Subject: [PATCH 025/193] Share reassigned bindings with generic arrow instances through closures A generic arrow declared inside a function compiles to shared module instances that took the enclosing values they read as trailing parameters: a copy, so any binding written in the file was refused, including a counter the arrow itself increments. When one of its captures is reassigned, the instance is now a lifted function that receives every capture through a closure environment, and each call site calls a closure over its own boxes, the representation ordinary closures use. Writes from the instance, the frame and other closures meet in the same box, as in Node. Arrows whose captures never change keep the trailing parameters. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/generic-functions.ts | 108 ++++++++++++++++-- .../generic-arrow-mutable-captures.ts.json | 6 + .../corpus/generic-arrow-mutable-captures.ts | 86 ++++++++++++++ tests/diagnostics/generic-value-bindings.ts | 11 +- .../generic-value-bindings.ts.txt | 64 +++++------ .../generic-arrow-mutable-captures.ts.json | 1 + 6 files changed, 226 insertions(+), 50 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-mutable-captures.ts.json create mode 100644 tests/corpus/generic-arrow-mutable-captures.ts create mode 100644 tests/harness/corpus-costs/generic-arrow-mutable-captures.ts.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 8397bbc0a..50f5930f4 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -48,6 +48,7 @@ import { promiseCarriesDyn, } from "./function-returns.js"; import { bindingNeverReassigned, stripValueWrappers } from "./binding-analysis.js"; +import { declareContextLocal } from "./function-context.js"; import { extendInstantiationPath, MAX_INSTANTIATION_RECURSION, @@ -109,6 +110,13 @@ export interface GenericFnInfo { * bindings qualify — a shared copy is exact exactly as long as nothing * writes the original. */ captures?: FrameCapture[]; + /** "env": some capture is reassigned, so a copy cannot stand in for it. + * The instances are then LIFTED functions that receive every capture + * through a closure environment (boxed, shared with the declaring frame + * and every ordinary closure over it), and each call site calls a fresh + * closure over its own boxes. Absent: captures travel as trailing + * parameters. */ + captureMode?: "env"; /** The declaration of a generic arrow/function-expression binding. */ bindingDecl?: ts.VariableDeclaration; /** A module-scope binding that code can use before its declaration @@ -148,6 +156,8 @@ export interface FrameCapture { nameNode: ts.Node; name: string; type: IrType; + /** Something in the file writes the binding (bindingNeverReassigned). */ + reassigned: boolean; } export interface GenericInstance { @@ -304,6 +314,52 @@ export function lowerGenericCall( ? implicitCallInstance(lowerer, expr, info) : genericCallInstance(lowerer, expr, info); const args = lowerer.completeArgs(expr.arguments.nodes, instance.params, loc, expr); + if (info.captureMode === "env") { + // The environment form: a closure over this call site's boxes of the + // captured bindings (resolveKey threads them through any closures in + // between and boxes the origin), called at once. Arguments evaluate + // after the callee, as in JavaScript; building the closure only reads + // the boxes. + if ( + instance.params.some( + (p) => p.mode === "rest" || p.mode === "dynRest" || p.mode === "arguments", + ) + ) { + lowerer.unsupported( + "SC1090", + expr, + `generic arrow/function-expression bindings with rest parameters that write the frames of their enclosing functions ('${info.baseName}')`, + ); + } + const sources = info.captures!.map((capture) => { + const local = lowerer.resolveKey(capture.symbol, expr); + if (local === null || !typeEquals(local.type, capture.type)) { + lowerer.unsupported( + "SC1090", + expr, + `generic arrow/function-expression bindings sharing '${capture.name}' with their enclosing function when its storage differs from its declared type ('${info.baseName}')`, + ); + } + // An origin read from its own frame is not boxed by resolveKey; the + // closure needs the box (captureContextBinding's origin rule). + local.boxed = true; + return local.id; + }); + const call: IrExpr & { kind: "callValue" } = { + kind: "callValue", + callee: { + kind: "closure", + fnName: instance.name, + captures: sources, + type: funcTypeFromParamShapes(instance.params, instance.returnType), + loc, + }, + args, + type: instance.returnType, + loc, + }; + return guardEarlyBindingCall(lowerer, info, expr, call); + } // The enclosing-frame values follow the declared arguments, the order // the instance takes them in (lowerGenericInstance). for (const capture of info.captures ?? []) { @@ -1226,7 +1282,10 @@ export function lowerGenericInstance( let bodyReturn = isGenerator ? lowerer.genBodyReturnType(inst.returnType) : lowerer.bodyReturnType(isAsync, inst.returnType); - const fnCtx = newFnCtx(false, null, null, bodyReturn); + // The environment form lowers as a LIFTED function: its captures arrive + // in the closure environment, never as parameters. + const envForm = info.captureMode === "env"; + const fnCtx = newFnCtx(envForm, null, null, bodyReturn); fnCtx.isAsync = isAsync; // Instances lower on demand, on top of whichever caller reached them. fnCtx.thisBoundary = true; @@ -1270,6 +1329,26 @@ export function lowerGenericInstance( const thisLocal = lowerer.declareThis(thisType); params.push({ localId: thisLocal.id, name: "this", type: thisType }); } + // Environment form: one boxed capture entry per captured binding, in + // FrameCapture order (the call sites' closure caps[] order), keyed by + // the binding's symbol so the body (parameter defaults included) + // reads and writes the shared box. Pre-registering them also keeps + // resolution from walking past this instance's frame. + if (envForm) { + for (const capture of info.captures!) { + const entry = declareContextLocal( + fnCtx, + capture.name, + capture.type, + capture.reassigned, + undefined, + undefined, + ); + entry.boxed = true; + fnCtx.captureBySymbol.set(capture.symbol, entry); + fnCtx.captures!.push({ localId: entry.id, name: entry.name, type: entry.type }); + } + } // Default-param initializers lower per instance, with the bindings // threaded — a default mentioning T resolves like any body expression. const declared = lowerer.declareParams(decl.parameters.nodes, inst.params); @@ -1278,7 +1357,7 @@ export function lowerGenericInstance( // declared ones: a call lowers its arguments first (JavaScript's // order), then reads the captures. Binding each local to the captured // symbol is what lets the body resolve the reference. - for (const capture of info.captures ?? []) { + for (const capture of envForm ? [] : (info.captures ?? [])) { const local = lowerer.declareLocal(capture.nameNode, capture.name, capture.type, false); params.push({ localId: local.id, name: capture.name, type: capture.type }); } @@ -1336,6 +1415,7 @@ export function lowerGenericInstance( params, returnType: bodyReturn, locals: lowerer.ctx.locals, + ...(envForm ? { captures: fnCtx.captures! } : {}), body, loc: locOf(decl), }; @@ -2678,9 +2758,10 @@ function capturesEnclosingFrame( * Module-scope declarations and the binding itself are reachable from the * module functions the instances compile to, so they are not captures; nor * are type-only declarations, whose mapping is static. `this`, `super`, - * `arguments`, `new.target`, and an enclosing generic's type parameter (or - * any binding the closure could observe a LATER write of) have no value a - * parameter can stand in for and stay fenced. */ + * `arguments`, `new.target`, and an enclosing generic's type parameter + * have no value a parameter can stand in for and stay fenced. A + * reassigned binding is shared through the environment form instead of + * copied. */ function enclosingFrameCaptures( lowerer: Lowerer, fn: ts.FunctionExpression | ts.ArrowFunction, @@ -2734,10 +2815,10 @@ function enclosingFrameCaptures( if ((s.flags & ts.SymbolFlags.TypeParameter) !== 0) frameFence(); // Type-only declarations cost no runtime slot. if ((s.flags & ts.SymbolFlags.Value) === 0) break; - // The instance holds a COPY of the value, exact only while the - // original never changes: a write anywhere in the file could - // make the closure observe a different binding state later. - if (!bindingNeverReassigned(lowerer, s, d)) frameFence(); + // A trailing-parameter COPY is exact only while the original + // never changes; a binding written anywhere in the file puts the + // whole binding in the environment form (GenericFnInfo.captureMode). + const reassigned = !bindingNeverReassigned(lowerer, s, d); // ...and exact only once the original is INITIALIZED. A capture // declared after the binding could be read from a call that runs // before its initializer — a temporal dead zone JavaScript would @@ -2756,6 +2837,7 @@ function enclosingFrameCaptures( nameNode, name: ts.isIdentifier(nameNode) ? nameNode.text : ts.symbolName(s), type, + reassigned, }); seen.add(s); break; @@ -2994,8 +3076,9 @@ export function bindingGenericFnInfoOf( if (fnNode.asteriskToken) lowerer.unsupported("SC1071", fnNode); // Inside a function, the compiled instances are module functions: the // values the initializer reads from the enclosing frame travel as - // trailing parameters (FunctionCapture). `this`, `arguments` and an - // enclosing generic's type parameters have no value to pass and stay + // trailing parameters (FrameCapture), or through a closure environment + // when one of them is reassigned (captureMode). `this`, `arguments` and + // an enclosing generic's type parameters have no value to pass and stay // fenced. let functionScoped = false; let captures: FrameCapture[] | undefined; @@ -3056,6 +3139,7 @@ export function bindingGenericFnInfoOf( instances: new Map(), ...(functionScoped ? { functionScoped: true as const } : {}), ...(captures ? { captures } : {}), + ...(captures?.some((capture) => capture.reassigned) ? { captureMode: "env" as const } : {}), bindingDecl: decl, }; lowerer.bindingGenericFns.set(fnNode, info); @@ -3239,7 +3323,7 @@ function guardEarlyBindingCall( lowerer: Lowerer, info: GenericFnInfo, expr: ts.CallExpression, - call: IrExpr & { kind: "call" }, + call: IrExpr & { kind: "call" | "callValue" }, ): IrExpr { if (info.bindingDecl === undefined || runsAfterDeclaration(lowerer, info, expr)) return call; const loc = call.loc; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-mutable-captures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-mutable-captures.ts.json new file mode 100644 index 000000000..401f68286 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-arrow-mutable-captures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-arrow-mutable-captures.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-arrow-mutable-captures.ts b/tests/corpus/generic-arrow-mutable-captures.ts new file mode 100644 index 000000000..50cb53e6e --- /dev/null +++ b/tests/corpus/generic-arrow-mutable-captures.ts @@ -0,0 +1,86 @@ +// Generic arrow bindings inside functions that WRITE a binding of the +// enclosing frame (`++callCount`, `total += n`) or read one the frame +// reassigns. Every instance shares the frame's binding with the frame +// and with ordinary closures over it: writes from either side are seen by +// the other, as in Node. +interface Result { + ok: boolean; +} + +// The counter idiom: an async generic arrow numbering its calls, called +// from closures in a returned record, with a reassignment outside. +function makeRunner(prefix: string): { run: (n: number) => Promise } { + let callCount = 0; + const ids: string[] = []; + const runCall = async (name: string, make: () => Promise): Promise => { + const id = `${prefix}/${name}/${++callCount}`; + ids.push(id); + const result = await make(); + ids.push(`${id}:${result.ok}`); + return result; + }; + return { + run: async (n) => { + for (let i = 0; i < n; i++) { + await runCall("a", async () => ({ ok: true })); + const b = await runCall("b", async () => ({ ok: false, extra: i })); + ids.push(`extra=${b.extra}`); + } + callCount += 100; + await runCall("c", async () => ({ ok: true })); + return ids; + }, + }; +} + +// Sync: two instances (different T) sharing two captured lets, a call from +// the declaring frame and from a nested arrow, a parameter captured and +// reassigned, and a frame write between calls. +function accumulate(start: number): string { + let total = start; + let last = "none"; + const add = (item: T): T => { + total += item.n; + last = `${item.n}@${start}`; + return item; + }; + add({ n: 1 }); + const later = (): { n: number; tag: string } => add({ n: 2, tag: "x" }); + const tagged = later(); + total = total * 10; + start = -1; + add({ n: 3 }); + return `${total} ${last} ${tagged.tag}`; +} + +// A generic arrow that reads a binding only the frame writes, called in a +// loop: each call sees the current value, never a copy from an earlier one. +function snapshots(): string { + let step = 0; + const seen: string[] = []; + const note = (value: T): T => { + seen.push(`${step}:${String(value)}`); + return value; + }; + for (; step < 3; step++) note(step % 2 === 0 ? "even" : step); + note(true); + return seen.join(" "); +} + +// A recursive generic arrow over a captured counter. +function depth(): number { + let calls = 0; + const walk = (items: T[], index: number): number => { + calls++; + return index >= items.length ? 0 : 1 + walk(items, index + 1); + }; + return walk(["a", "b", "c"], 0) * 10 + calls; +} + +async function main(): Promise { + console.log((await makeRunner("p").run(2)).join(" ")); + console.log(accumulate(5)); + console.log(snapshots()); + console.log(depth()); +} +main(); diff --git a/tests/diagnostics/generic-value-bindings.ts b/tests/diagnostics/generic-value-bindings.ts index 18e41ee77..e13f3d9e2 100644 --- a/tests/diagnostics/generic-value-bindings.ts +++ b/tests/diagnostics/generic-value-bindings.ts @@ -19,14 +19,13 @@ console.log(typeof stored); const flipAlias = flip; console.log(flipAlias(5)); -// Declared inside a function and reading a binding that is written after -// the binding: the instance would hold a copy, while the closure reads -// whatever the write left behind. (A binding that is never reassigned is -// supported — it travels as a trailing parameter.) +// Declared inside a function and reading a binding declared AFTER it: a +// call could run before the binding's initializer (a temporal dead zone). +// (Bindings declared before it are supported — copied as trailing +// parameters, or shared through a closure environment when reassigned.) function outer(base: number): number { - let scale = base; const inner = (x: T): [T, number] => [x, scale]; - scale += 1; + const scale = base + 1; return inner(3)[1]; } console.log(outer(4)); diff --git a/tests/harness/__snapshots__/generic-value-bindings.ts.txt b/tests/harness/__snapshots__/generic-value-bindings.ts.txt index 28c1e9bc6..d09dcce80 100644 --- a/tests/harness/__snapshots__/generic-value-bindings.ts.txt +++ b/tests/harness/__snapshots__/generic-value-bindings.ts.txt @@ -57,62 +57,62 @@ generic-value-bindings.ts:20:13 - error SC2004: uses of 'flipAlias' inherit the hint: the declaration of 'flipAlias' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:28:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet +generic-value-bindings.ts:27:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet - 27 | let scale = base; - 28 | const inner = (x: T): [T, number] => [x, scale]; + 26 | function outer(base: number): number { + 27 | const inner = (x: T): [T, number] => [x, scale]; | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 29 | scale += 1; + 28 | const scale = base + 1; -generic-value-bindings.ts:30:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration +generic-value-bindings.ts:29:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration - 29 | scale += 1; - 30 | return inner(3)[1]; + 28 | const scale = base + 1; + 29 | return inner(3)[1]; | ^~~~~ - 31 | } + 30 | } hint: the declaration of 'inner' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:37:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet (instantiating 'outerGeneric' with ) +generic-value-bindings.ts:36:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet (instantiating 'outerGeneric' with ) - 36 | function outerGeneric(seed: U): string { - 37 | const inner = (x: T): string => `${String(x)}${String(seed)}`; + 35 | function outerGeneric(seed: U): string { + 36 | const inner = (x: T): string => `${String(x)}${String(seed)}`; | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 38 | return inner(3); + 37 | return inner(3); -generic-value-bindings.ts:38:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration (instantiating 'outerGeneric' with ) +generic-value-bindings.ts:37:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration (instantiating 'outerGeneric' with ) - 37 | const inner = (x: T): string => `${String(x)}${String(seed)}`; - 38 | return inner(3); + 36 | const inner = (x: T): string => `${String(x)}${String(seed)}`; + 37 | return inner(3); | ^~~~~ - 39 | } + 38 | } hint: the declaration of 'inner' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:47:10 - error SC1090: generic arrow/function-expression bindings declared inside functions as values (each call of the enclosing function makes a new 'same' function object, while its compiled instances are shared module functions — call 'same' directly, or declare it at module scope) are not supported yet +generic-value-bindings.ts:46:10 - error SC1090: generic arrow/function-expression bindings declared inside functions as values (each call of the enclosing function makes a new 'same' function object, while its compiled instances are shared module functions — call 'same' directly, or declare it at module scope) are not supported yet - 46 | const same = (x: T): T => x; - 47 | return same; + 45 | const same = (x: T): T => x; + 46 | return same; | ^~~~~~~~~~~~ - 48 | } + 47 | } -generic-value-bindings.ts:60:13 - error SC1090: results that are themselves generic functions ('(y: U) => string' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments) are not supported yet +generic-value-bindings.ts:59:13 - error SC1090: results that are themselves generic functions ('(y: U) => string' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments) are not supported yet - 59 | const curried = (x: T) => (y: U): string => `${x}|${y}`; - 60 | console.log(curried(1)("a")); + 58 | const curried = (x: T) => (y: U): string => `${x}|${y}`; + 59 | console.log(curried(1)("a")); | ^~~~~~~~~~ - 61 | + 60 | -generic-value-bindings.ts:72:15 - error SC1090: calls of the generic method 'm' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize) is not supported yet +generic-value-bindings.ts:71:15 - error SC1090: calls of the generic method 'm' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize) is not supported yet - 71 | function throughParam(a: Amb): void { - 72 | console.log(a.m(4)); + 70 | function throughParam(a: Amb): void { + 71 | console.log(a.m(4)); | ^~~~~~ - 73 | } + 72 | } -generic-value-bindings.ts:83:32 - error SC1090: generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding 'picked' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses) are not supported yet +generic-value-bindings.ts:82:32 - error SC1090: generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding 'picked' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses) are not supported yet - 82 | } - 83 | const invoke = (): number => picked(1); + 81 | } + 82 | const invoke = (): number => picked(1); | ^~~~~~ - 84 | return invoke(); \ No newline at end of file + 83 | return invoke(); \ No newline at end of file diff --git a/tests/harness/corpus-costs/generic-arrow-mutable-captures.ts.json b/tests/harness/corpus-costs/generic-arrow-mutable-captures.ts.json new file mode 100644 index 000000000..50963cb4d --- /dev/null +++ b/tests/harness/corpus-costs/generic-arrow-mutable-captures.ts.json @@ -0,0 +1 @@ +{ "plain": 3793, "san": 7466 } From d540a649e6a3563357d66d73addd7c9d2628e3c2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:41:17 -0700 Subject: [PATCH 026/193] fs.watch: hand listeners the changed entry name and an EventEmitter surface An fs.watch listener may now take (), (eventType), or (eventType, filename). On a directory watch the filename is the entry that changed; a file watch names the watched file's basename. The parameter must admit null (Node passes null when the platform names nothing). macOS directory watches that ask for the name are refused with SC2020 until the FSEvents slice lands. FSWatcher gains its EventEmitter surface for "change", "error" and "close" (on, once, addListener, prependListener, prependOnceListener, off, removeListener). The name and error adapters are emitted per program because each builds the listener's own parameter shapes, and the erased `string | null` nullable union takes the payload directly instead of a union box. The runtime keeps one listener list per event with the once flag and prepend ordering; a once listener's registration reference leaves when it fires. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/fs.d.ts | 28 +- packages/compiler/src/backend/llvm/emitter.ts | 10 + .../src/backend/llvm/expr-stream-callbacks.ts | 149 +++++++++- .../src/backend/llvm/lib-filesystem.ts | 83 +++++- .../frontend/lowering/builtins/filesystem.ts | 128 +++++++-- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 7 + packages/runtime/src/scr_watch.c | 270 ++++++++++++++++-- packages/runtime/src/scr_watch.h | 41 ++- 9 files changed, 649 insertions(+), 69 deletions(-) diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 534fad940..46c38a315 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -555,11 +555,29 @@ declare module "node:fs" { ): void; /* fs.watch — the file-watching slice (scr_watch.c, kqueue EVFILT_VNODE): * "rename"/"change" events on ONE path, watcher.close() to stop. The - * listener takes zero parameters or the eventType string (the filename - * parameter has no lowering); an open watcher keeps the loop alive. A - * path that cannot be opened THROWS Node's fs error synchronously. */ + * listener takes (), (eventType) or (eventType, filename); an open + * watcher keeps the loop alive. A path that cannot be opened THROWS + * Node's fs error synchronously. */ + export type WatchEventType = "rename" | "change"; + /* (eventType, filename): filename is the changed entry's name (null when + * the platform does not provide one). */ + export type WatchListener = (event: WatchEventType, filename: T | null) => void; export interface FSWatcher { close(): void; + on(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "close", listener: () => void): this; + once(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this; + once(event: "error", listener: (error: Error) => void): this; + once(event: "close", listener: () => void): this; + addListener( + event: "change", + listener: (eventType: string, filename: string | Buffer) => void, + ): this; + addListener(event: "error", listener: (error: Error) => void): this; + addListener(event: "close", listener: () => void): this; + off(event: "change" | "error" | "close", listener: (...args: any[]) => void): this; + removeListener(event: "change" | "error" | "close", listener: (...args: any[]) => void): this; } /* The options-record stance (see http.RequestOptions): persistent: true, * recursive: false, and encoding: "utf8" state the lowered behavior and @@ -571,11 +589,11 @@ declare module "node:fs" { encoding?: string; [option: string]: unknown; } - export function watch(path: string, listener?: (eventType: string) => void): FSWatcher; + export function watch(path: string, listener?: WatchListener): FSWatcher; export function watch( path: string, options: WatchOptions, - listener?: (eventType: string) => void, + listener?: WatchListener, ): FSWatcher; /* fs.promises IS the fs/promises module (Node's rule) — the namespace- * import form `fs.promises.readFile(...)` lowers through the same diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6980c8f45..1addb5cb7 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -285,6 +285,8 @@ import { cryptoIntThunkFor, type BytesThunkValue, fsRenameThunkFor, + watchNameThunkFor, + watchErrorThunkFor, streamCbThunkFor, zlibBytesThunkFor, } from "./expr-stream-callbacks.js"; @@ -8157,6 +8159,14 @@ export class LlEmitter { return fsRenameThunkFor(this, cbT); } + watchNameThunkFor(cbT: IrType & { kind: "func" }): string { + return watchNameThunkFor(this, cbT); + } + + watchErrorThunkFor(cbT: IrType & { kind: "func" }): string { + return watchErrorThunkFor(this, cbT); + } + cryptoBytesThunkFor( cbT: IrType & { kind: "func" }, arrayBuffer = false, diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 3d9d8da76..00ee4d636 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -1,7 +1,7 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { type IrType, isRefCounted, typeKey } from "../../ir/ir.js"; -import { FN_ATTRS, releaseSym, retainSym, traceArg } from "./shapes.js"; +import { type IrType, isRefCounted, STRING, typeKey } from "../../ir/ir.js"; +import { FN_ATTRS, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { unionPeekLines, unionTagLines } from "./union-repr.js"; import { registerReplay } from "./emit-journal.js"; @@ -966,6 +966,151 @@ registerReplay("streamDoneFnFor", (host, args) => registerReplay("fsRenameThunkFor", (host, args) => fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }), ); +/** The (eventType, filename) fs.watch listener adapter: the runtime hands + * the borrowed C strings, the thunk builds what the listener declared. The + * listener owns its +1 arguments (the universal convention + * scr_watch_thunk_event follows). */ +export function watchNameThunkFor( + host: LlvmEmitterContext, + cbT: IrType & { kind: "func" }, +): string { + const key = `watchname:${typeKey(cbT)}`; + let sym = host.resolveThunks.get(key); + if (sym) return sym; + sym = host.contentSymbol("sc_watchname_", key); + host.resolveThunks.set(key, sym); + host.journal?.allocated(sym, "watchNameThunkFor", [cbT]); + const S = host.sizeType; + host.declare(`declare ${S} @strlen(ptr)`); + host.declare(`declare ptr @scr_str_new(ptr, ${S})`); + host.declare(`declare void @scr_str_release(ptr)`); + const d: string[] = [ + `define internal void @${sym}(ptr %cb, ptr %event, ptr %name) ${FN_ATTRS} { ; fs.watch listener ${typeKey(cbT)}`, + `entry:`, + ` %evlen = call ${S} @strlen(ptr %event)`, + ` %evstr = call ptr @scr_str_new(ptr %event, ${S} %evlen)`, + ` %slot = alloca ptr`, + ` %has = icmp ne ptr %name, null`, + ` br i1 %has, label %yes, label %no`, + `yes:`, + ]; + const nameParam = cbT.params[1]!; + if (nameParam.kind === "dyn") { + host.declare(`declare ptr @scr_dyn_new_null()`); + host.declare(`declare ptr @scr_dyn_new_str(ptr)`); + d.push( + ` %nlen = call ${S} @strlen(ptr %name)`, + ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, + ` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`, + ` call void @scr_str_release(ptr %nstr)`, + ` store ptr %ndyn, ptr %slot`, + ` br label %go`, + `no:`, + ` %dn = call ptr @scr_dyn_new_null()`, + ` store ptr %dn, ptr %slot`, + ); + } else { + if (nameParam.kind !== "union") + throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union"); + const def = host.unionsById.get(nameParam.unionId); + const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; + const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; + if (strTag < 0 || nullTag < 0) + throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms"); + if (host.nullableUnions.has(nameParam.unionId)) { + // `string | null` erases: the owned payload IS the union's value. + d.push( + ` %nlen = call ${S} @strlen(ptr %name)`, + ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, + ` store ptr %nstr, ptr %slot`, + ` br label %go`, + `no:`, + ` store ptr null, ptr %slot`, + ); + } else { + const rc = vAdapters(host.shapeHost, STRING); + host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); + d.push( + ` %nlen = call ${S} @strlen(ptr %name)`, + ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, + ` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`, + ` store ptr %nu, ptr %slot`, + ` br label %go`, + `no:`, + ` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`, + ); + } + } + let ev: string; + if (cbT.params[0]?.kind === "dyn") { + host.declare(`declare ptr @scr_dyn_new_str(ptr)`); + d.push(` br label %go`, `go:`, ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`); + ev = "%evd"; + } else { + d.push(` br label %go`, `go:`); + ev = "%evstr"; + } + d.push( + ` %arg = load ptr, ptr %slot`, + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`, + ` ret void`, + `}`, + ``, + ); + host.resolveThunkDefs.push(...d); + host.journal?.built(); + return sym; +} + +/** The FSWatcher 'error' listener adapter: the runtime hands a borrowed + * ScrError, the listener receives the declared dyn value or an Error + * object it owns. A zero-parameter listener uses the runtime's + * scr_watch_err_thunk0 and never reaches here. */ +export function watchErrorThunkFor( + host: LlvmEmitterContext, + cbT: IrType & { kind: "func" }, +): string { + const key = `watcherr:${typeKey(cbT)}`; + let sym = host.resolveThunks.get(key); + if (sym) return sym; + sym = host.contentSymbol("sc_watcherr_", key); + host.resolveThunks.set(key, sym); + host.journal?.allocated(sym, "watchErrorThunkFor", [cbT]); + const param = cbT.params[0]!; + const d: string[] = [ + `define internal void @${sym}(ptr %cb, ptr %err) ${FN_ATTRS} { ; FSWatcher error listener ${typeKey(cbT)}`, + `entry:`, + ]; + if (param.kind === "dyn") { + host.declare(`declare ptr @scr_dyn_from_error(ptr)`); + d.push(` %de = call ptr @scr_dyn_from_error(ptr %err)`); + } else if (param.kind === "object" && param.className === "%Error") { + host.declare(`declare ptr @scr_error_retain(ptr)`); + d.push(` %de = call ptr @scr_error_retain(ptr %err)`); + } else { + throw new InternalCompilerError("llvm emitter bug: FSWatcher error param is not an Error"); + } + d.push( + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ` call void %fn(ptr %cb, ptr %de)`, + ` ret void`, + `}`, + ``, + ); + host.resolveThunkDefs.push(...d); + host.journal?.built(); + return sym; +} + +registerReplay("watchNameThunkFor", (host, args) => + watchNameThunkFor(host, args[0] as IrType & { kind: "func" }), +); +registerReplay("watchErrorThunkFor", (host, args) => + watchErrorThunkFor(host, args[0] as IrType & { kind: "func" }), +); registerReplay("cryptoIntThunkFor", (host, args) => cryptoIntThunkFor(host, args[0] as IrType & { kind: "func" }), ); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 9174f4775..310f2cb57 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -448,17 +448,19 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): // loop (usesTimers). host.usesTimers = true; const path = host.emitExpr(e.args[0]!); - host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`); + host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`); const t = B.tmp(); - B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null)`); + B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null, i1 false)`); const out = host.own({ name: t, type: e.type }); host.emitPendingCheck(); return out; } if (e.fn === "fs.watchCb") { // The callback MOVES into the watcher's registry; the adapter is - // runtime-provided per listener shape (zero-param, or the eventType - // string). May throw (ENOENT) — the standard pending check. + // runtime-provided per listener shape (zero-param, the eventType + // string, or the compiler's (eventType, filename) adapter). May + // throw (ENOENT, or the macOS directory-names refusal) — the + // standard pending check. host.usesTimers = true; const cbT = e.args[1]!.type; if (cbT.kind !== "func") @@ -466,15 +468,80 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): const path = host.emitExpr(e.args[0]!); const cb = host.emitExpr(e.args[1]!); host.toHeap(cb); - const adapter = cbT.params.length === 0 ? "scr_watch_thunk0" : "scr_watch_thunk_event"; - host.declare(`declare void @${adapter}(ptr, ptr)`); - host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`); + const adapter = + cbT.params.length === 0 + ? "scr_watch_thunk0" + : cbT.params.length === 1 + ? "scr_watch_thunk_event" + : host.watchNameThunkFor(cbT); + // The named form is a definition this module emits; only the runtime's + // fixed adapters need a declaration. + if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`); + host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`); const t = B.tmp(); - B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter})`); + B.line( + `${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${cbT.params.length === 2 ? "true" : "false"})`, + ); const out = host.own({ name: t, type: e.type }); host.emitPendingCheck(); return out; } + if (e.fn === "watcher.on") { + // (recv, event, flags, listener) — flags: 1 once, 2 prepend. The + // listener MOVES in; the adapter follows the event (0 change, 1 + // error, 2 close — CLOSE takes no adapter, a plain zero-arg call). + // Registration can throw (the macOS directory-names refusal). + const flagsArg = e.args[2]!; + if (flagsArg.kind !== "numLit") + throw new InternalCompilerError("llvm emitter bug: watcher.on flags not a literal"); + const flags = flagsArg.value; + const codeArg = e.args[1]!; + if (codeArg.kind !== "numLit") + throw new InternalCompilerError("llvm emitter bug: watcher.on event not a literal"); + const code = codeArg.value; + const cbT = e.args[3]!.type; + if (cbT.kind !== "func") + throw new InternalCompilerError("llvm emitter bug: watcher.on listener not a func"); + const w = host.emitExpr(e.args[0]!); + const event = host.emitExpr(e.args[1]!); + const cb = host.emitExpr(e.args[3]!); + host.toHeap(cb); + let adapter = "null"; + let wantsName = false; + if (code === 0) { + adapter = + cbT.params.length === 0 + ? "scr_watch_thunk0" + : cbT.params.length === 1 + ? "scr_watch_thunk_event" + : host.watchNameThunkFor(cbT); + wantsName = cbT.params.length === 2; + // The named form is a definition this module emits; only the runtime's + // fixed adapters need a declaration. + if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`); + } else if (code === 1) { + adapter = cbT.params.length === 0 ? "scr_watch_err_thunk0" : host.watchErrorThunkFor(cbT); + if (cbT.params.length === 0) host.declare(`declare void @scr_watch_err_thunk0(ptr, ptr)`); + } + host.declare( + `declare void @scr_watcher_on(ptr, double, ptr, ptr, i1 zeroext, i1 zeroext, i1 zeroext)`, + ); + B.line( + `call void @scr_watcher_on(ptr ${w.name}, double ${event.name}, ptr ${cb.name}, ptr ${adapter === "null" ? "null" : `@${adapter}`}, i1 ${flags & 1 ? "true" : "false"}, i1 ${flags & 2 ? "true" : "false"}, i1 ${wantsName ? "true" : "false"})`, + ); + host.emitPendingCheck(); + return { name: "", type: e.type }; + } + if (e.fn === "watcher.off") { + // Removal is by pointer identity: the listener stays borrowed + // (never heap-moved), exactly like process.offExit's. + const w = host.emitExpr(e.args[0]!); + const event = host.emitExpr(e.args[1]!); + const cb = host.emitExpr(e.args[2]!); + host.declare(`declare void @scr_watcher_off(ptr, double, ptr)`); + B.line(`call void @scr_watcher_off(ptr ${w.name}, double ${event.name}, ptr ${cb.name})`); + return { name: "", type: e.type }; + } if (e.fn === "watcher.close") { // Idempotent; never throws. const w = host.emitExpr(e.args[0]!); diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index d8272c795..d248caa5e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -1110,13 +1110,37 @@ export function lowerStatsMethodCall( ); } +/** The watch-listener shapes: () , (eventType: string), and + * (eventType: string, filename: string | null) — the filename parameter + * must admit null (Node passes null when the platform names nothing), or + * be the checked dynamic. Returns the parameter count, or null when the + * shape has no lowering. */ +function watchListenerArity(lowerer: Lowerer, cb: IrExpr): 0 | 1 | 2 | null { + if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 2) + return null; + const [event, name] = cb.type.params; + if (event !== undefined && event.kind !== "string" && event.kind !== "dyn") return null; + if (name !== undefined && name.kind !== "dyn") { + const def = name.kind === "union" ? lowerer.unions.get(name.unionId) : undefined; + if ( + !def || + !def.arms.some((a) => a.kind === "nullT") || + !def.arms.some((a) => a.kind === "string") || + !def.arms.every((a) => a.kind === "nullT" || a.kind === "undefinedT" || a.kind === "string") + ) + return null; + } + return cb.type.params.length as 0 | 1 | 2; +} + /** `fs.watch(path[, options][, listener])` → the fs.watch libCall * (scr_watch.c — kqueue EVFILT_VNODE on the opened path; an unopenable * path THROWS Node's fs error synchronously, the polling-fallback catch * shape). The listener fires with "rename"/"change" and takes zero - * parameters or the eventType string — the filename parameter has no - * lowering (kqueue watches the inode, not the directory entry; you - * watched one path). The options record follows the options-record + * parameters, the eventType string, or (eventType, filename: string | + * null) — the filename is the entry name on a directory watch and the + * watched file's basename otherwise (Linux inotify names both; a macOS + * directory watch has no name source until F3). The options record follows the options-record * stance: persistent: true, recursive: false, and encoding: "utf8" * state the lowered behavior and are accepted; persistent: false * (a watcher that does NOT hold the loop), recursive: true (kqueue @@ -1187,7 +1211,7 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: lowerer.noLowering( "fs.watch with a non-utf8 encoding", prop, - "the encoding applies to the filename argument, which has no lowering — utf8 (the default) is accepted", + "the filename argument is a string — utf8 (the default) is the accepted encoding", ); } if (key === "signal") { @@ -1219,19 +1243,11 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: : null; if (listenerArg !== null) { const cb = lowerer.lowerExpr(listenerArg); - if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { + if (watchListenerArity(lowerer, cb) === null) { lowerer.noLowering( "fs.watch with this listener shape", listenerArg, - "the listener takes () or (eventType: string) — the filename parameter has no lowering (you watched one path)", - ); - } - const param = cb.type.params[0]; - if (param !== undefined && param.kind !== "string") { - lowerer.unsupported( - "SC1090", - listenerArg, - `watch listeners whose parameter is not the eventType string (got '${lowerer.fmt(param)}')`, + "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)", ); } args.push(cb); @@ -1245,10 +1261,12 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: }; } -/** Method calls on FSWatcher receivers: close() — idempotent, statement - * position (Node returns void there too). Everything else @types/node - * declares (ref/unref, the EventEmitter surface) fences member- - * qualified. Null for non-watcher receivers. */ +/** Method calls on FSWatcher receivers: close() (idempotent, statement + * position — Node returns void there too) and the EventEmitter surface for + * 'change', 'error' and 'close' (on/once/addListener/prependListener/ + * prependOnceListener/off/removeListener, registered as their own + * statement). Everything else @types/node declares (ref/unref, other + * events) fences member-qualified. Null for non-watcher receivers. */ export function lowerWatcherMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -1263,10 +1281,82 @@ export function lowerWatcherMethodCall( const receiver = lowerer.lowerExpr(access.expression); return { kind: "libCall", fn: "watcher.close", args: [receiver], type: VOID, loc }; } + // The EventEmitter surface for the three events the watcher emits: + // "change" (the listener shapes fs.watch takes), "error", and "close". + const ON = ["on", "once", "addListener", "prependListener", "prependOnceListener"]; + if ( + (ON.includes(name) || name === "off" || name === "removeListener") && + call.arguments.nodes.length === 2 + ) { + const evT = lowerer.typeOf(call.arguments.nodes[0]!); + const event = evT.isStringLiteralType() ? evT.value : null; + const code = event === "change" ? 0 : event === "error" ? 1 : event === "close" ? 2 : -1; + if (code < 0) { + lowerer.noLowering( + `FSWatcher.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, + call.arguments.nodes[0]!, + '"change", "error", and "close" are the supported FSWatcher events (as literals)', + ); + } + const parent = call.parent; + if ( + !ts.isExpressionStatement(parent) && + !(ts.isArrowFunction(parent) && parent.body === call) + ) { + lowerer.unsupported( + "SC1090", + call, + `using the result of FSWatcher.${name} (register it as its own statement)`, + ); + } + const receiver = lowerer.lowerExpr(access.expression); + const cb = lowerer.lowerExpr(call.arguments.nodes[1]!); + if (name === "off" || name === "removeListener") { + return { + kind: "libCall", + fn: "watcher.off", + args: [receiver, numLit(code, loc), cb], + type: VOID, + loc, + }; + } + const ok = + code === 0 + ? watchListenerArity(lowerer, cb) !== null + : code === 1 + ? cb.type.kind === "func" && + cb.type.ret.kind === "void" && + cb.type.params.length <= 1 && + (cb.type.params[0] === undefined || + cb.type.params[0].kind === "dyn" || + (cb.type.params[0].kind === "object" && cb.type.params[0].className === "%Error")) + : cb.type.kind === "func" && cb.type.ret.kind === "void" && cb.type.params.length === 0; + if (!ok) { + lowerer.noLowering( + `FSWatcher.${name}("${event}", ...) with this listener shape`, + call.arguments.nodes[1]!, + code === 0 + ? "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)" + : code === 1 + ? "the listener takes () or (error: Error)" + : "the listener takes ()", + ); + } + const flags = + (name === "once" || name === "prependOnceListener" ? 1 : 0) | + (name.startsWith("prepend") ? 2 : 0); + return { + kind: "libCall", + fn: "watcher.on", + args: [receiver, numLit(code, loc), numLit(flags, loc), cb], + type: VOID, + loc, + }; + } lowerer.noLowering( `FSWatcher.${name}`, call, - "close() is the supported FSWatcher member", + 'close(), and on/once/addListener/prependListener/prependOnceListener/off/removeListener for "change", "error", and "close" are the supported FSWatcher members', lowerer.checker.getSymbolAtLocation(access.name), ); } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..48580f4fe 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -1088,6 +1088,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.closeSync", "fs.watch", "fs.watchCb", + "watcher.on", + "watcher.off", "crypto.x509Fingerprint", "crypto.x509FingerprintStr", "crypto.x509ValidFrom", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..47514c561 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1304,6 +1304,13 @@ function buildLibFnSignatures() { "fs.watch": { argTypes: [STRING], result: FSWATCHER_T }, "fs.watchCb": { argTypes: [STRING, null], result: FSWATCHER_T }, "watcher.close": { argTypes: [FSWATCHER_T], result: VOID }, + // FSWatcher's EventEmitter surface: (receiver, event code, once|prepend + // flags, listener) for on/once/addListener/prependListener/ + // prependOnceListener, and (receiver, event code, listener) for + // off/removeListener. The listener's func type is program-dependent; + // the null slot pins the arity and the receiver. + "watcher.on": { argTypes: [FSWATCHER_T, F64, F64, null], result: VOID }, + "watcher.off": { argTypes: [FSWATCHER_T, F64, null], result: VOID }, /** The composed `new crypto.X509Certificate(data).fingerprint` read * (scr_lib.c — no certificate handle exists): the SHA-1 of the DER, * uppercase colon-separated, over PEM or raw-DER Buffer input; other diff --git a/packages/runtime/src/scr_watch.c b/packages/runtime/src/scr_watch.c index f86adb219..0d5087961 100644 --- a/packages/runtime/src/scr_watch.c +++ b/packages/runtime/src/scr_watch.c @@ -57,12 +57,21 @@ #include #include #include +#include #include #if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || \ defined(__OpenBSD__) || defined(__DragonFly__) #define SCR_HAVE_KQUEUE 1 #include +#include +/* Darwin directory watches deliver through FSEvents in libuv, which names + * entries; the kqueue arm watches the directory inode and cannot. Until + * slice F3 lands FSEvents, a listener that reads the filename has no + * lowering there. */ +#if defined(__APPLE__) +#define SCR_WATCH_DIR_NAMES_REFUSED 1 +#endif #elif defined(__linux__) #define SCR_HAVE_INOTIFY 1 #include @@ -92,14 +101,35 @@ static void scr_watch_oom(void) { abort(); } +enum { SCR_WATCH_EV_CHANGE = 0, SCR_WATCH_EV_ERROR = 1, SCR_WATCH_EV_CLOSE = 2 }; + +/* One registered listener: the event it answers, its adapter, and whether + * it is one-shot (once/prependOnceListener — removed before it runs). */ typedef struct { - ScrClosure *cb; /* owned */ - ScrWatchFn fn; /* adapter: event name borrowed */ + ScrClosure *cb; /* owned */ + void *fn; /* CHANGE: ScrWatchFn; ERROR: ScrWatchErrFn; CLOSE: NULL (zero-arg call) */ + uint8_t event; + bool once; } ScrWatchL; +/* The 'close' emission: the moved-out listeners, called once on the next + * tick in registration order. The batch owns them, so a watcher released + * before the tick leaves nothing dangling. */ +typedef struct { + size_t rc; + ScrWatchL *ls; + size_t n; +} ScrWatchCloseBatch; + +static void *scr_watch_batch_retain(void *p); +static void scr_watch_batch_release(void *p); + struct ScrWatcher { size_t rc; int fd; /* -1 once closed, or after a failed reopen (no further events) */ +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + bool is_dir; /* the opened path is a directory (the F3 gap) */ +#endif #ifdef SCR_HAVE_INOTIFY int wd; /* the inotify watch descriptor; -1 once dead (IN_IGNORED * arrived or close() removed it) */ @@ -124,6 +154,24 @@ struct ScrWatcher { static SCR_TL ScrWatcher *scr_watchers = NULL; static SCR_TL size_t scr_watchers_open = 0; /* liveness: open (unclosed) watchers */ +static void scr_watch_close_tick(ScrClosure *cb); + +#if defined(SCR_HAVE_INOTIFY) || defined(SCR_HAVE_KQUEUE) +/* libuv's uv__basename_r: the part after the last '/', or the whole path. */ +static const char *scr_watch_basename(const char *path) { + const char *slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} +#endif + +#if defined(SCR_WATCH_DIR_NAMES_REFUSED) +static void scr_watch_refuse_dir_names(void) { + const char *m = + "fs.watch of a directory with a listener that reads the filename has no lowering on macOS yet [SC2020]"; + scr_throw_error_msg_code(SCR_ERR_ERROR, m, strlen(m), "SC2020"); +} +#endif + #ifdef SCR_HAVE_KQUEUE static SCR_TL int scr_watch_kq = -1; /* created with the first watch; lives forever */ @@ -238,7 +286,8 @@ void scr_watcher_release_v(void *p) { scr_watcher_release((ScrWatcher *)p); } /* fs.watch(path, cb?) — opens the path NOW (throwing Node's fs error on * failure), registers the watcher with the loop, and arms the vnode * filter. cb (nullable) moves in as the first listener. Result +1. */ -ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn) { +ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn, + bool wants_name) { #ifdef SCR_HAVE_INOTIFY /* The wd IS the validation: Node's Linux throw comes straight from * inotify_add_watch's errno, the same message shape as the open below. */ @@ -295,6 +344,22 @@ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWa scr_fs_throw(errno, "watch", path); return NULL; /* exception pending */ } +#endif +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + /* The path opened: a DIRECTORY watch on macOS has no name source before + * F3, so a listener that reads the filename is refused HERE, before the + * watcher is registered. */ + bool is_dir = false; + { + struct stat st; + if (fstat(fd, &st) == 0 && S_ISDIR(st.st_mode)) is_dir = true; + } + if (wants_name && is_dir) { + close(fd); + if (cb != NULL) scr_closure_release(cb); + scr_watch_refuse_dir_names(); + return NULL; /* exception pending */ + } #endif ScrWatcher *w = calloc(1, sizeof *w); if (!w) scr_watch_oom(); @@ -305,8 +370,13 @@ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWa if (!w->ls) scr_watch_oom(); w->ls[0].cb = cb; w->ls[0].fn = fn; + w->ls[0].event = SCR_WATCH_EV_CHANGE; + w->ls[0].once = false; w->n = w->cap = 1; } +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + w->is_dir = is_dir; +#endif #ifdef SCR_HAVE_INOTIFY w->fd = -1; /* inotify holds the watch; there is no per-watcher fd */ w->wd = wd; @@ -348,6 +418,21 @@ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWa void scr_watcher_close(ScrWatcher *w) { if (w->closed) return; w->closed = true; + /* The 'close' listeners leave the LIVE list and run on the next tick, + * in registration order, exactly once (Node emits 'close' after the + * first close() returns). */ + ScrWatchL *closes = NULL; + size_t ncloses = 0; + if (w->n > 0) { + closes = malloc(w->n * sizeof *closes); + if (!closes) scr_watch_oom(); + size_t kept = 0; + for (size_t i = 0; i < w->n; i++) { + if (w->ls[i].event == SCR_WATCH_EV_CLOSE) closes[ncloses++] = w->ls[i]; + else w->ls[kept++] = w->ls[i]; + } + w->n = kept; + } #ifdef SCR_HAVE_INOTIFY if (w->wd >= 0) { /* The queued IN_IGNORED this rm produces finds no open watcher and is @@ -366,6 +451,20 @@ void scr_watcher_close(ScrWatcher *w) { } scr_watcher_drop_listeners(w); scr_watchers_open--; + if (ncloses > 0) { + ScrWatchCloseBatch *batch = malloc(sizeof *batch); + if (!batch) scr_watch_oom(); + batch->rc = 1; /* the box owns it */ + batch->ls = closes; + batch->n = ncloses; + ScrClosure *tick = scr_closure_new((void *)scr_watch_close_tick, 1); + tick->caps[0] = + scr_box_new_obj(scr_watch_batch_retain, scr_watch_batch_release, NULL); + scr_box_set_ref(tick->caps[0], batch); + scr_next_tick(tick); + } else { + free(closes); + } } /* Unlinks and releases closed watchers — called only where no fetched @@ -385,30 +484,118 @@ static void scr_watch_sweep(void) { } /* The runtime-provided listener adapters. */ -void scr_watch_thunk0(ScrClosure *cb, const char *event) { +void scr_watch_thunk0(ScrClosure *cb, const char *event, const char *filename) { (void)event; + (void)filename; ((void (*)(ScrClosure *))cb->fn)(cb); } -void scr_watch_thunk_event(ScrClosure *cb, const char *event) { +void scr_watch_thunk_event(ScrClosure *cb, const char *event, const char *filename) { + (void)filename; /* The listener owns its +1 param per the universal convention. */ ScrStr *e = scr_str_new(event, strlen(event)); ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, e); } +/* The zero-parameter 'error' listener: the error itself is dropped. */ +void scr_watch_err_thunk0(ScrClosure *cb, ScrError *err) { + (void)err; + ((void (*)(ScrClosure *))cb->fn)(cb); +} -/* Fires one watcher's listeners with the event name (snapshot — a - * listener closing the watcher mid-emit still finishes this delivery, - * and close() drops only the LIVE list). */ -static void scr_watch_fire(ScrWatcher *w, const char *event) { - size_t n = w->n; - if (n == 0) return; - ScrWatchL *snap = malloc(n * sizeof *snap); - if (!snap) scr_watch_oom(); - for (size_t i = 0; i < n; i++) { - snap[i] = w->ls[i]; - scr_closure_retain(snap[i].cb); +/* watcher.on/once/addListener/prependListener(event, listener). A closed + * watcher can never fire again, so the listener leaves at once. */ +void scr_watcher_on(ScrWatcher *w, double event, ScrClosure *cb, void *fn, bool once, bool prepend, + bool wants_name) { + if (w->closed) { + scr_closure_release(cb); + return; } +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + if (event == SCR_WATCH_EV_CHANGE && wants_name && w->is_dir) { + scr_closure_release(cb); + scr_watch_refuse_dir_names(); + return; + } +#else + (void)wants_name; +#endif + if (w->n == w->cap) { + size_t cap = w->cap == 0 ? 1 : w->cap * 2; + ScrWatchL *grown = realloc(w->ls, cap * sizeof *grown); + if (!grown) scr_watch_oom(); + w->ls = grown; + w->cap = cap; + } + ScrWatchL entry = {cb, fn, (uint8_t)event, once}; + if (prepend) { + memmove(w->ls + 1, w->ls, w->n * sizeof *w->ls); + w->ls[0] = entry; + } else { + w->ls[w->n] = entry; + } + w->n++; +} + +/* watcher.off/removeListener(event, listener): the most recently added + * listener of that event with the same closure identity leaves. */ +void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb) { + for (size_t i = w->n; i > 0; i--) { + ScrWatchL *l = &w->ls[i - 1]; + if (l->event != (uint8_t)event || l->cb != cb) continue; + scr_closure_release(l->cb); + memmove(l + 1, l, (w->n - i) * sizeof *w->ls); + w->n--; + return; + } +} + +static void *scr_watch_batch_retain(void *p) { + ScrWatchCloseBatch *b = p; + if (b->rc != SIZE_MAX) b->rc++; + return b; +} + +static void scr_watch_batch_release(void *p) { + ScrWatchCloseBatch *b = p; + if (!b || b->rc == SIZE_MAX) return; + if (--b->rc != 0) return; + for (size_t i = 0; i < b->n; i++) scr_closure_release(b->ls[i].cb); + free(b->ls); + free(b); +} + +static void scr_watch_close_tick(ScrClosure *cb) { + ScrWatchCloseBatch *b = scr_box_get_ref(cb->caps[0]); + if (b == NULL) return; + for (size_t i = 0; i < b->n; i++) { + if (!scr_exc_pending()) ((void (*)(ScrClosure *))b->ls[i].cb->fn)(b->ls[i].cb); + } + scr_watch_batch_release(b); +} + +/* Fires one watcher's CHANGE listeners with the event name and the entry + * name (NULL when the platform named nothing). The snapshot follows the + * existing shape — a listener closing the watcher mid-emit still finishes + * this delivery — and a one-shot listener leaves the LIVE list before it + * runs (EventEmitter's rule). */ +static void scr_watch_fire(ScrWatcher *w, const char *event, const char *filename) { + if (w->n == 0) return; + ScrWatchL *snap = malloc(w->n * sizeof *snap); + if (!snap) scr_watch_oom(); + size_t n = 0, kept = 0; + for (size_t i = 0; i < w->n; i++) { + ScrWatchL l = w->ls[i]; + if (l.event != SCR_WATCH_EV_CHANGE) { + w->ls[kept++] = l; + continue; + } + snap[n++] = l; + scr_closure_retain(l.cb); + if (!l.once) w->ls[kept++] = l; + else scr_closure_release(l.cb); /* the live list's own reference leaves now */ + } + w->n = kept; for (size_t i = 0; i < n; i++) { - if (!scr_exc_pending()) snap[i].fn(snap[i].cb, event); + if (!scr_exc_pending()) ((ScrWatchFn)snap[i].fn)(snap[i].cb, event, filename); scr_closure_release(snap[i].cb); } free(snap); @@ -448,6 +635,16 @@ static void scr_watch_dispatch(void) { if (w == NULL || w->closed || w->fd < 0) continue; uint32_t ff = evs[i].fflags; bool rename = (ff & (NOTE_RENAME | NOTE_DELETE | NOTE_REVOKE)) != 0; + /* The entry name BEFORE the reopen dance closes the old fd: kqueue + * names the watched inode by its CURRENT path (libuv's + * F_GETPATH rule). A directory watch names the directory itself — + * only the zero/one-parameter listeners, which ignore it, see it. */ + const char *name = NULL; +#ifdef F_GETPATH + char pathbuf[MAXPATHLEN]; + if (w->fd >= 0 && fcntl(w->fd, F_GETPATH, pathbuf) == 0) + name = scr_watch_basename(pathbuf); +#endif if (rename) { /* The inode left the name: reopen best-effort so the atomic * replace-by-rename idiom keeps delivering on the new file. The @@ -459,9 +656,9 @@ static void scr_watch_dispatch(void) { close(w->fd); w->fd = -1; } - scr_watch_fire(w, "rename"); + scr_watch_fire(w, "rename", name); } else if (ff & (NOTE_WRITE | NOTE_EXTEND | NOTE_ATTRIB)) { - scr_watch_fire(w, "change"); + scr_watch_fire(w, "change", name); } if (scr_exc_pending()) return; /* the loop surfaces it */ } @@ -489,7 +686,10 @@ static void scr_watch_dispatch(void) { bool change = (e->mask & (IN_ATTRIB | IN_MODIFY)) != 0; if (e->mask & IN_IGNORED) w->wd = -1; /* the kernel dropped the watch: * no further events (no reopen), like Node */ - scr_watch_fire(w, change ? "change" : "rename"); + /* libuv's filename rule: the entry name for a directory event, the + * watched path's basename otherwise (uv__basename_r). */ + const char *name = e->len ? e->name : scr_watch_basename(w->path->data); + scr_watch_fire(w, change ? "change" : "rename", name); if (scr_exc_pending()) return; /* the loop surfaces it */ } if (scr_loop_has_ready()) return; /* microtasks interleave first */ @@ -510,18 +710,36 @@ static void scr_watch_dispatch(void) { * overflow notification, delivered as a filterless "rename" burst * signal the same way libuv degrades. */ if (ok && bytes == 0) { - scr_watch_fire(w, "rename"); + scr_watch_fire(w, "rename", NULL); /* the overflow notification names nothing */ if (scr_exc_pending()) return; } else if (ok) { char *p = w->buf; for (;;) { FILE_NOTIFY_INFORMATION *e = (FILE_NOTIFY_INFORMATION *)p; + size_t wlen = e->FileNameLength / sizeof(WCHAR); /* not NUL-terminated */ bool match = w->filter == NULL || - CompareStringOrdinal(e->FileName, (int)(e->FileNameLength / sizeof(WCHAR)), - w->filter, -1, TRUE) == CSTR_EQUAL; + CompareStringOrdinal(e->FileName, (int)wlen, w->filter, -1, TRUE) == CSTR_EQUAL; if (match) { - scr_watch_fire(w, e->Action == FILE_ACTION_MODIFIED ? "change" : "rename"); + char stackbuf[1024]; + char *heap = NULL; + char *namebuf = stackbuf; + size_t namecap = sizeof stackbuf; + if (wlen * 4 + 1 > namecap) { + heap = malloc(wlen * 4 + 1); + if (!heap) scr_watch_oom(); + namebuf = heap; + namecap = wlen * 4 + 1; + } + int nbytes = WideCharToMultiByte(CP_UTF8, 0, e->FileName, (int)wlen, namebuf, + (int)namecap - 1, NULL, NULL); + char *name = NULL; + if (nbytes > 0) { + namebuf[nbytes] = '\0'; + name = namebuf; + } + scr_watch_fire(w, e->Action == FILE_ACTION_MODIFIED ? "change" : "rename", name); + free(heap); if (scr_exc_pending()) return; if (w->closed) break; /* a listener closed its own watcher */ } @@ -530,6 +748,10 @@ static void scr_watch_dispatch(void) { } } if (!w->closed && w->dh != INVALID_HANDLE_VALUE && !scr_watch_issue(w)) { + /* TODO(windows): EPERM 'error' event — Node emits one when the + * watched directory disappears (GetOverlappedResult fails with + * ERROR_ACCESS_DENIED, libuv's UV_EPERM). Unverified on Windows; + * the watcher stops emitting, like the kqueue exhaustion stance. */ scr_watch_backend_drop(w); /* reissue refused: no further events, like kqueue exhaustion */ } } diff --git a/packages/runtime/src/scr_watch.h b/packages/runtime/src/scr_watch.h index c038dbe55..c2118d1cd 100644 --- a/packages/runtime/src/scr_watch.h +++ b/packages/runtime/src/scr_watch.h @@ -1,9 +1,12 @@ -/* scriptc runtime — fs.watch (scr_watch.c). Included by scr_runtime.h, which - * says where new declarations go. */ +/* scriptc runtime — fs.watch (scr_watch.c) and fs.watchFile + * (scr_watch_file.c). Included by scr_runtime.h, which says where new + * declarations go. */ #ifndef SCR_WATCH_H #define SCR_WATCH_H #include "scr_bytes.h" +#include "scr_error.h" +#include "scr_lib.h" /* ── fs.watch (scr_watch.c — compiled only when the program uses it; * design note atop the file). FSWatcher handles over the unit's own @@ -12,9 +15,12 @@ * close/exit — the child ownership story), an open watcher keeps the * loop alive until close() (Node's persistent default). */ typedef struct ScrWatcher ScrWatcher; -/* The event adapter: the name ("rename"/"change") arrives BORROWED - * (static storage); adapters build what their listener declared. */ -typedef void (*ScrWatchFn)(ScrClosure *cb, const char *event); +/* The event adapter: the event name ("rename"/"change") and the entry + * name (NULL when the platform names nothing) arrive BORROWED (static + * storage); adapters build what their listener declared. */ +typedef void (*ScrWatchFn)(ScrClosure *cb, const char *event, const char *filename); +/* The 'error' adapter: the ScrError arrives borrowed. */ +typedef void (*ScrWatchErrFn)(ScrClosure *cb, ScrError *err); ScrWatcher *scr_watcher_retain(ScrWatcher *w); void scr_watcher_release(ScrWatcher *w); @@ -23,13 +29,26 @@ void scr_watcher_release_v(void *p); /* fs.watch(path, listener?) — path borrowed, the callback (nullable) * MOVES. Opens the path NOW and THROWS Node's fs error when it cannot - * ("ENOENT: ..., watch 'x'" — the polling-fallback catch shape). +1. */ -ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb, ScrWatchFn fn); + * ("ENOENT: ..., watch 'x'" — the polling-fallback catch shape). A + * CHANGE listener that reads the filename (wants_name) on a macOS + * directory watch has no lowering yet and THROWS [SC2020]. +1. */ +ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb, ScrWatchFn fn, bool wants_name); void scr_watcher_close(ScrWatcher *w); /* idempotent */ -/* The runtime-provided listener adapters (zero-param, and the one-param - * (eventType: string) shape). */ -void scr_watch_thunk0(ScrClosure *cb, const char *event); -void scr_watch_thunk_event(ScrClosure *cb, const char *event); +/* watcher.on/once/addListener/prependListener(event, listener): cb MOVES + * in. On a closed watcher the listener is released at once (it can never + * run). Directory watches on macOS cannot name entries (slice F3), so a + * CHANGE listener that reads the filename (wants_name) THROWS there. */ +void scr_watcher_on(ScrWatcher *w, double event, ScrClosure *cb, void *fn, bool once, bool prepend, + bool wants_name); +/* watcher.off/removeListener(event, listener): removes the most recently + * added listener of that event whose closure is `cb` (pointer identity, + * EventEmitter's rule); cb is borrowed. */ +void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb); +/* The runtime-provided listener adapters: the zero- and one-parameter + * fs.watch shapes, and the zero-parameter 'error' listener. */ +void scr_watch_thunk0(ScrClosure *cb, const char *event, const char *filename); +void scr_watch_thunk_event(ScrClosure *cb, const char *event, const char *filename); +void scr_watch_err_thunk0(ScrClosure *cb, ScrError *err); void scr_watch_install(void); /* The loop-side registration (scr_async.c, always linked) — the net * hook's exact shape, one more nullable slot set. */ From 7176257136cc294d12db5431b22b3caf5ced36a9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:41:23 -0700 Subject: [PATCH 027/193] fs.watchFile: poll a path from its own gated runtime unit fs.watchFile(path[, options], listener) polls the resolved path every `interval` milliseconds (5007 by default, `persistent` by default) and calls its listener with (current, previous) Stats when a poll sees a difference. The first poll only records the baseline, a path that cannot be stat'd polls as all-zero Stats, and a stat failure that repeats for the same errno is not re-reported. One poller per RESOLVED path is shared by every listener on it, and fs.unwatchFile(path[, listener]) removes the last listener registered with that callback, or every listener when called without one. The call is a statement, since Node's StatWatcher has no lowering, and bigint: true is refused. The implementation is scr_watch_file.c, a new gated runtime unit switched by moduleUsesFsWatchFile, so programs that do not poll link none of it. The feature travels the usual chain (runtime-features, executable-features, the native link info, the executable cache keys, the runtime-pack matrix) and the validator learns that fs.unwatchFile's trailing listener slot may be omitted. Evidence: - tests/corpus/fs-watch-file-names.ts and fs-watch-file-polling.ts match Node on stdout, stderr and exit status in the plain and sanitized lanes - 1564-fs-watch.ts, 1751-options-record-stance.ts and the `fs` corpus stay green; the new order baselines and corpus cost files are recorded Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 2 + .../builtin-apis/file-watching.mdx | 5 + .../src/generated/node-v24-compatibility.json | 18 +- .../generated/node-v24-internal.json | 30 +- internal/compatibility/static-support.json | 3 +- .../compiler/ambient/node-fallback/fs.d.ts | 22 ++ .../src/backend/executable-features.ts | 1 + packages/compiler/src/backend/library-pack.ts | 1 + .../compiler/src/backend/llvm/emit-module.ts | 8 + .../src/backend/llvm/lib-filesystem.ts | 34 ++ .../src/backend/native-link-info-core.ts | 2 + .../compiler/src/backend/native/contracts.ts | 5 + .../compiler/src/backend/native/executable.ts | 3 + .../src/backend/runtime-pack-targets.test.ts | 1 + .../compiler/src/backend/runtime-pack.test.ts | 1 + packages/compiler/src/backend/warm-cache.ts | 1 + .../src/executable/executable-cache.test.ts | 1 + .../src/executable/executable-cache.ts | 2 + .../src/frontend/lowering/builtin-calls.ts | 8 + .../frontend/lowering/builtins/filesystem.ts | 123 +++++++ .../src/frontend/lowering/surfaces.ts | 7 + packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/api.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 10 + packages/compiler/src/ir/runtime-features.ts | 13 + packages/compiler/src/ir/validate.ts | 9 +- packages/compiler/src/native/cli.ts | 1 + packages/compiler/src/native/prepare.ts | 1 + .../compiler/src/native/sanitizer.test.ts | 1 + .../tests/corpus/fs-watch-file-names.ts.json | 6 + .../corpus/fs-watch-file-polling.ts.json | 6 + .../runtime-pack-matrix.mjs | 1 + packages/runtime/src/scr_lib.c | 37 +- packages/runtime/src/scr_lib.h | 5 + packages/runtime/src/scr_watch.h | 19 + packages/runtime/src/scr_watch_file.c | 331 ++++++++++++++++++ tests/corpus/fs-watch-file-names.ts | 60 ++++ tests/corpus/fs-watch-file-polling.ts | 49 +++ .../corpus-costs/fs-watch-file-names.ts.json | 1 + .../fs-watch-file-polling.ts.json | 1 + 41 files changed, 811 insertions(+), 22 deletions(-) create mode 100644 docs/content/limitations/builtin-apis/file-watching.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json create mode 100644 packages/runtime/src/scr_watch_file.c create mode 100644 tests/corpus/fs-watch-file-names.ts create mode 100644 tests/corpus/fs-watch-file-polling.ts create mode 100644 tests/harness/corpus-costs/fs-watch-file-names.ts.json create mode 100644 tests/harness/corpus-costs/fs-watch-file-polling.ts.json diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 88b2ca5fc..ad8912a40 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -100,6 +100,8 @@ title: "Limitations" ../limitations/builtin-apis/file-streams.mdx +../limitations/builtin-apis/file-watching.mdx + ../limitations/builtin-apis/crypto.mdx ../limitations/builtin-apis/assertions.mdx diff --git a/docs/content/limitations/builtin-apis/file-watching.mdx b/docs/content/limitations/builtin-apis/file-watching.mdx new file mode 100644 index 000000000..2d2b386f0 --- /dev/null +++ b/docs/content/limitations/builtin-apis/file-watching.mdx @@ -0,0 +1,5 @@ +- **File watching:** `fs.watch` returns an `FSWatcher` whose `on`, `once`, `off`, and `close` take `change`, `rename`, `error`, and `close` listeners. + A listener may take the event name and the entry name, but the name arrives as a string (Node's default encoding), so a listener annotated with the `Buffer` arm of `@types/node`'s `change` overload has no lowering. + Watching a directory on macOS with a listener that reads the entry name is refused with `SC2020` because FSEvents is not implemented yet; file watches and listeners that ignore the name work on every target. +- **Stat polling:** `fs.watchFile` polls the resolved path every `interval` milliseconds (default 5007) and calls its listener with `(current, previous)` `Stats`; `fs.unwatchFile` removes the last listener registered with that callback, or every listener when called without one. + The call must be a statement of its own, because the `StatWatcher` it returns in Node.js is not lowered, and `bigint: true` is refused. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 387f5b21c..391d7a5f8 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -30842,9 +30842,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -30911,9 +30911,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -32659,9 +32659,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 00d6f8db3..a93e7b34c 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -44292,6 +44292,7 @@ "evidence": "derived:descendants", "tests": [ "tests/corpus/1403-buffer-fs.ts", + "tests/corpus/1564-fs-watch.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2686-fs-file-handle.ts", "tests/corpus/2844-node-disposable-resources.ts", @@ -44328,6 +44329,8 @@ "tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs", "tests/corpus/fs-native-realpath.ts", + "tests/corpus/fs-watch-file-names.ts", + "tests/corpus/fs-watch-file-polling.ts", "tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", "tests/corpus/node24-fs-links-options.cjs", @@ -47429,8 +47432,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-dedicated:fs.unwatchFile", + "tests": [ + "tests/corpus/1564-fs-watch.ts", + "tests/corpus/fs-watch-file-names.ts", + "tests/corpus/fs-watch-file-polling.ts" + ] }, "anchorSource": "exact" }, @@ -47601,8 +47609,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-dedicated:fs.watchFile", + "tests": [ + "tests/corpus/1564-fs-watch.ts", + "tests/corpus/fs-watch-file-names.ts", + "tests/corpus/fs-watch-file-polling.ts" + ] }, "anchorSource": "exact" }, @@ -49669,8 +49682,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-dedicated:fs.FSWatcher", + "tests": [ + "tests/corpus/1564-fs-watch.ts", + "tests/corpus/fs-watch-file-names.ts", + "tests/corpus/fs-watch-file-polling.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1242ad9a7..8afe8af22 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -485,6 +485,7 @@ { "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, { "symbols": ["readline.createInterface", "rl.close"], "status": "partial", "evidence": ["tests/corpus/1475-readline-closed-stdin.ts", "tests/corpus/5878-readline-dynamic-streams.mjs"] }, { "symbols": ["test", "it", "test.skip", "test.todo", "test.only", "describe", "suite", "before", "after", "beforeEach", "afterEach", "context.test", "context.skip", "context.todo", "context.assert"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] }, - { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] } + { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] }, + { "symbols": ["fs.FSWatcher", "fs.watchFile", "fs.unwatchFile"], "status": "partial", "evidence": ["tests/corpus/1564-fs-watch.ts", "tests/corpus/fs-watch-file-names.ts", "tests/corpus/fs-watch-file-polling.ts"] } ] } diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 46c38a315..70172f384 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -595,6 +595,28 @@ declare module "node:fs" { options: WatchOptions, listener?: WatchListener, ): FSWatcher; + /* fs.watchFile — stat polling (scr_watch_file.c, libuv's uv_fs_poll + * shape): the listener runs with (current, previous) Stats when a poll + * sees a difference, the first poll only records the baseline, and a + * path that cannot be stat'd polls as all-zero Stats. One poller per + * resolved path is shared by every listener on it. interval defaults to + * 5007 ms, persistent defaults to true; bigint: true (BigIntStats) has + * no lowering. The call is a statement: Node's StatWatcher has no + * lowering. */ + export interface StatWatcher {} + export function watchFile( + filename: string, + options: { interval?: number; persistent?: boolean; bigint?: false }, + listener: (current: Stats, previous: Stats) => void, + ): StatWatcher; + export function watchFile( + filename: string, + listener: (current: Stats, previous: Stats) => void, + ): StatWatcher; + export function unwatchFile( + filename: string, + listener?: (current: Stats, previous: Stats) => void, + ): void; /* fs.promises IS the fs/promises module (Node's rule) — the namespace- * import form `fs.promises.readFile(...)` lowers through the same * fs/promises table as `import { readFile } from "node:fs/promises"`. */ diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..bb4542573 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -66,6 +66,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ dgramDyn: features.dgramDyn, textCodecDyn: features.textCodecDyn, watch: features.fsWatch, + watchFile: features.fsWatchFile, foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []), nodeTest: features.nodeTest, tls: features.tls, diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts index 14ffdff28..7b74dc808 100644 --- a/packages/compiler/src/backend/library-pack.ts +++ b/packages/compiler/src/backend/library-pack.ts @@ -56,6 +56,7 @@ export async function compilePackedLibrary( dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 6f261d4bb..bcdb985f4 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -478,6 +478,10 @@ export function emitModuleParts(emitter: LlEmitter): string[] { const usesEvents = runtimeFeatures.processEvents; const usesChildProcess = runtimeFeatures.childProcess; const usesFsWatch = runtimeFeatures.fsWatch; + // fs.watchFile programs register the poller unit's exit cleanup — the + // same one-line gating (native/executable.ts links scr_watch_file.c on + // the same predicate). + const usesFsWatchFile = runtimeFeatures.fsWatchFile; // Programs that call into Node record the libnode pack and its Node // version before %main (scr_node_island.c). const usesNodeIsland = runtimeFeatures.nodeIsland; @@ -541,6 +545,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { if (workerStdioBox || workerStdinBox) emitter.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`); if (usesFsWatch) emitter.declare(`declare void @scr_watch_install()`); + if (usesFsWatchFile) emitter.declare(`declare void @scr_watch_file_install()`); if (usesNodeIsland) emitter.declare(`declare void @scr_node_island_install(ptr, ptr)`); // Interned now: the C-string constants flush before main assembles. const nodeIslandConfig = usesNodeIsland @@ -1226,6 +1231,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // fs.watch programs fill the loop's watch hooks the same way — // scr_watch.c links only when this line is emitted. ...(usesFsWatch ? [` call void @scr_watch_install()`] : []), + // fs.watchFile programs register the poller unit's atexit cleanup the + // same way — scr_watch_file.c links only when this line is emitted. + ...(usesFsWatchFile ? [` call void @scr_watch_file_install()`] : []), ...(nodeIslandConfig ? [ ` call void @scr_node_island_install(ptr ${nodeIslandConfig[0]}, ptr ${nodeIslandConfig[1]})`, diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 310f2cb57..dc9c5c876 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -549,6 +549,40 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): B.line(`call void @scr_watcher_close(ptr ${w.name})`); return { name: "", type: e.type }; } + if (e.fn === "fs.watchFile") { + // (path, interval, persistent, listener) — the listener MOVES into + // the poller's registry and the adapter is runtime-provided per + // listener shape (zero, one or two Stats parameters). The poller + // re-arms a timer every interval, so this is a timer program. + host.usesTimers = true; + const cbT = e.args[3]!.type; + if (cbT.kind !== "func") + throw new InternalCompilerError("llvm emitter bug: fs.watchFile listener not a func"); + const path = host.emitExpr(e.args[0]!); + const interval = host.emitExpr(e.args[1]!); + const persistent = host.emitExpr(e.args[2]!); + const cb = host.emitExpr(e.args[3]!); + host.toHeap(cb); + const adapter = `scr_watch_file_thunk${cbT.params.length}`; + host.declare(`declare void @${adapter}(ptr, ptr, ptr)`); + host.declare(`declare void @scr_fs_watch_file(ptr, double, i1 zeroext, ptr, ptr)`); + B.line( + `call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`, + ); + return { name: "", type: e.type }; + } + if (e.fn === "fs.unwatchFile") { + // Removal is by closure identity: the listener stays borrowed + // (never heap-moved), exactly like watcher.off's; an absent one is + // the NULL that removes every listener on the path. + const path = host.emitExpr(e.args[0]!); + const cb = e.args[1] === undefined ? null : host.emitExpr(e.args[1]!); + host.declare(`declare void @scr_fs_unwatch_file(ptr, ptr)`); + B.line( + `call void @scr_fs_unwatch_file(ptr ${path.name}, ptr ${cb === null ? "null" : cb.name})`, + ); + return { name: "", type: e.type }; + } if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") { // Dirent rows from one glob snapshot (scr_glob.c): mode 1 is the // globSync form, 3 the fs/promises form with resolved parent paths. diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..10d665790 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -116,6 +116,8 @@ export interface NativeLinkFeatures { * scr_text_codec_dyn.c. */ textCodecDyn: boolean; watch: boolean; + /** fs.watchFile/fs.unwatchFile: scr_watch_file.c. */ + watchFile: boolean; foreignFfi: boolean; nodeTest: boolean; tls: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..aca6000b4 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -255,6 +255,11 @@ export interface CcOptions { * scr_watch.c into the binary — the net gating precedent, so watch-free * binaries keep their exact link line. */ watch?: boolean; + /** The program uses fs.watchFile or fs.unwatchFile (moduleUsesFsWatchFile + * on the IR): compiles scr_watch_file.c into the binary — its own gated + * unit under the same precedent, so polling-free binaries keep their + * exact link line. */ + watchFile?: boolean; /** The executable manifest has a format-5 foreign callback descriptor: * compiles the MPSC queue/self-pipe unit. Other FFI and non-FFI binaries * keep their existing runtime size class. */ diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..e8ae31067 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -336,6 +336,9 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"), unit((u) => u.opts.textCodecDyn, "scr_text_codec_dyn.c"), unit((u) => u.opts.watch, "scr_watch.c"), + // fs.watchFile's stat poller: its own gated unit (no loop hook — the + // re-armed timers are the liveness), linked only by programs that poll. + unit((u) => u.opts.watchFile, "scr_watch_file.c"), unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"), unit((u) => u.opts.nodeTest, "scr_test.c"), unit((u) => u.opts.webcrypto, "scr_webcrypto.c"), diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index fcbe3f08e..510e046b6 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -120,6 +120,7 @@ const features = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index a12c137b2..0fc279d20 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -61,6 +61,7 @@ const BASE: NativeLinkFeatures = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts index c42ceb79e..25254934f 100644 --- a/packages/compiler/src/backend/warm-cache.ts +++ b/packages/compiler/src/backend/warm-cache.ts @@ -63,6 +63,7 @@ export async function warmNativeCaches( dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index 1f5625fb8..9c81ccc19 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -60,6 +60,7 @@ const native: EarlyExecutableNativeFeatures = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..c7daf3f68 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -109,6 +109,7 @@ export interface EarlyExecutableNativeFeatures { dgramDyn: boolean; textCodecDyn: boolean; watch: boolean; + watchFile: boolean; foreignFfi: boolean; nodeTest: boolean; tls: boolean; @@ -295,6 +296,7 @@ const BOOLEAN_NATIVE_KEYS = [ "dgramDyn", "textCodecDyn", "watch", + "watchFile", "foreignFfi", "nodeTest", "tls", diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index d2e4b53d5..33c4502bc 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -1,4 +1,5 @@ import { lowerReadlineCursorCall } from "./builtins/readline.js"; +import { lowerFsWatchFileCall } from "./builtins/filesystem.js"; import { lowerMonitorEventLoopDelayCall } from "./builtins/performance.js"; import { lowerEnvironmentData, @@ -134,6 +135,13 @@ export function lowerBuiltinCall( if (streamServed) return streamServed; const fsTs = lowerer.lowerFsToUnixTimestampCall(expr, bi, loc); if (fsTs) return fsTs; + // fs.watchFile/fs.unwatchFile have no table entry (their checked-dynamic + // form is the fs.watchFileChk ladder below), so the typed polling forms + // dispatch here; anything else falls through to the ladder and the fence. + if (bi.module === "fs" && (bi.member === "watchFile" || bi.member === "unwatchFile")) { + const polling = lowerFsWatchFileCall(lowerer, expr, loc, bi.member === "unwatchFile"); + if (polling) return polling; + } const fsLadder = lowerer.lowerFsLadderCall(expr, bi, loc); if (fsLadder) return fsLadder; const cryptoServed = lowerer.lowerCryptoModuleCall(expr, bi, loc); diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index d248caa5e..318d58ee0 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -8,6 +8,7 @@ import { builtinModuleFnOf, FS_READDIR_DOCUMENTED_OPTIONS, FS_WATCH_DOCUMENTED_OPTIONS, + FS_WATCH_FILE_DOCUMENTED_OPTIONS, fenceOrDropOptionKey, } from "../surfaces.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; @@ -1261,6 +1262,128 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: }; } +/** `fs.watchFile(path[, options], listener)` and + * `fs.unwatchFile(path[, listener])` → the polling libCalls + * (scr_watch_file.c: libuv's uv_fs_poll shape — one poller per RESOLVED + * path, shared by every listener on it, re-armed every `interval` ms, + * 5007 by default, persistent by default). The listener takes + * (current: Stats, previous: Stats) — zero, one or both — and runs when a + * poll sees a difference; the first poll only records the baseline, and a + * file that cannot be stat'd polls as all-zero Stats. The options record + * follows the options-record stance: interval, persistent: true/false and + * bigint: false are accepted; bigint: true fences (BigIntStats); other + * documented keys fence by name; undocumented keys drop like Node. + * Statement position only — Node returns a StatWatcher, which has no + * lowering. Returns null for any untyped shape so the checked-dynamic + * ladder (fs.watchFileChk) keeps its current IR. */ +export function lowerFsWatchFileCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + unwatch: boolean, +): IrExpr | null { + const nodes = expr.arguments.nodes; + if (nodes.length < 1 || nodes.length > 3 || nodes.some(ts.isSpreadElement)) return null; + if (lowerer.mapTypeOf(lowerer.typeOf(nodes[0]!))?.kind !== "string") return null; + const path = lowerer.lowerExprExpecting(nodes[0]!, STRING); + if (unwatch) { + if (nodes.length > 2) return null; + const args: IrExpr[] = [path]; + if (nodes.length === 2) { + if (lowerer.mapTypeOf(lowerer.typeOf(nodes[1]!))?.kind !== "func") return null; + args.push(lowerer.lowerExpr(nodes[1]!)); + } + return { kind: "libCall", fn: "fs.unwatchFile", args, type: VOID, loc }; + } + const hasOptions = nodes.length >= 2 && ts.isObjectLiteralExpression(nodes[1]!); + if (nodes.length === 3 && !hasOptions) return null; + if (nodes.length === 2 && hasOptions) return null; /* no listener */ + const listenerArg = nodes[nodes.length - 1]!; + if (lowerer.mapTypeOf(lowerer.typeOf(listenerArg))?.kind !== "func") return null; + const parent = expr.parent; + if (!ts.isExpressionStatement(parent) && !(ts.isArrowFunction(parent) && parent.body === expr)) { + lowerer.unsupported( + "SC1090", + expr, + "using the returned StatWatcher (register the fs.watchFile call as its own statement)", + ); + } + let interval: IrExpr | null = null; + let persistent: IrExpr | null = null; + if (hasOptions) { + for (const prop of (nodes[1] as ts.ObjectLiteralExpression).properties.nodes) { + if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) { + lowerer.noLowering( + "fs.watchFile options with computed keys or spreads", + prop, + "each option must be a plain `name: value` entry with a literal key", + ); + } + if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) { + lowerer.noLowering( + "fs.watchFile options with computed keys", + prop, + "each option must be a plain `name: value` entry with a literal key", + ); + } + const key = prop.name.text; + const value = ts.isPropertyAssignment(prop) + ? prop.initializer + : ts.isShorthandPropertyAssignment(prop) + ? prop.name + : null; + if (key === "interval" && value !== null) { + interval = lowerer.lowerExprExpecting(value, F64); + continue; + } + if (key === "persistent" && value !== null) { + persistent = lowerer.lowerExprExpecting(value, BOOL); + continue; + } + if (key === "bigint") { + // The fallback's option record declares `bigint: false`, so the + // false literal (or `false` as a value) states the lowered Stats + // shape; BigIntStats has no lowering. + if (value !== null && value.kind === ts.SyntaxKind.FalseKeyword) continue; + lowerer.noLowering( + "fs.watchFile with bigint: true", + prop, + "the listener receives Stats (double integers); BigIntStats has no lowering", + ); + } + fenceOrDropOptionKey( + lowerer, + prop, + key, + "fs.watchFile", + FS_WATCH_FILE_DOCUMENTED_OPTIONS, + "interval, persistent: true/false, and bigint: false are the accepted options", + ); + // An undocumented key, dropped like Node drops it. + } + } + const cb = lowerer.lowerExpr(listenerArg); + if ( + cb.type.kind !== "func" || + cb.type.ret.kind !== "void" || + cb.type.params.length > 2 || + !cb.type.params.every((p) => p.kind === "stats") + ) { + lowerer.noLowering( + "fs.watchFile with this listener shape", + listenerArg, + "the listener takes (current: Stats, previous: Stats)", + ); + } + return { + kind: "libCall", + fn: "fs.watchFile", + args: [path, interval ?? numLit(5007, loc), persistent ?? boolLit(true, loc), cb], + type: VOID, + loc, + }; +} + /** Method calls on FSWatcher receivers: close() (idempotent, statement * position — Node returns void there too) and the EventEmitter surface for * 'change', 'error' and 'close' (on/once/addListener/prependListener/ diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..6a06fbaad 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -443,6 +443,13 @@ export const FS_WATCH_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ "signal", ]); +/** fs.watchFile's documented option keys. */ +export const FS_WATCH_FILE_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ + "interval", + "persistent", + "bigint", +]); + /** fs.readdirSync's documented option keys. */ export const FS_READDIR_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ "encoding", diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..fdf2335a8 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -745,6 +745,7 @@ async function compileExecutableNative( dgramDyn: features.dgramDyn, textCodecDyn: features.textCodecDyn, watch: features.watch, + watchFile: features.watchFile, foreignFfi: features.foreignFfi, ...(features.workers ? { workers: true } : {}), ...(features.youngNursery ? { youngNursery: true } : {}), diff --git a/packages/compiler/src/ir/api.ts b/packages/compiler/src/ir/api.ts index 600cdb051..8ea02cfe6 100644 --- a/packages/compiler/src/ir/api.ts +++ b/packages/compiler/src/ir/api.ts @@ -28,6 +28,7 @@ export { moduleUsesQs, moduleUsesParseArgs, moduleUsesFsWatch, + moduleUsesFsWatchFile, moduleUsesNodeTest, moduleUsesDgram, moduleUsesHttpServer, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 48580f4fe..5733525cd 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -1090,6 +1090,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.watchCb", "watcher.on", "watcher.off", + "fs.watchFile", + "fs.unwatchFile", "crypto.x509Fingerprint", "crypto.x509FingerprintStr", "crypto.x509ValidFrom", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 47514c561..622abfd94 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1311,6 +1311,16 @@ function buildLibFnSignatures() { // the null slot pins the arity and the receiver. "watcher.on": { argTypes: [FSWATCHER_T, F64, F64, null], result: VOID }, "watcher.off": { argTypes: [FSWATCHER_T, F64, null], result: VOID }, + /** fs.watchFile(path, interval, persistent, listener) → void + * (scr_watch_file.c — its own gated unit; moduleUsesFsWatchFile is the + * switch, and scr_watch_file_install() is emitted with it). One + * libuv-shaped stat poller per RESOLVED path, shared by every listener + * on it; the listener's (current, previous) Stats func type is + * program-dependent, so the slot pins the arity. Statement position + * only (Node returns a StatWatcher). fs.unwatchFile(path[, listener]) + * takes a borrowed listener; the null slot is the absent one. */ + "fs.watchFile": { argTypes: [STRING, F64, BOOL, null], result: VOID }, + "fs.unwatchFile": { argTypes: [STRING, null], result: VOID }, /** The composed `new crypto.X509Certificate(data).fingerprint` read * (scr_lib.c — no certificate handle exists): the SHA-1 of the DER, * uppercase colon-separated, over PEM or raw-DER Buffer input; other diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..d583ea62a 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1069,6 +1069,9 @@ const RUNTIME_FEATURE_RULES = { fetch: rule({ prefixes: ["fetch."] }), fileHandle: rule({ types: ["fileHandle"] }), fsWatch: rule({ prefixes: ["fs.watch", "watcher."], types: ["fsWatcher"] }), + /** fs.watchFile / fs.unwatchFile (scr_watch_file.c): the stat-polling + * half of the watching surface, gated on its own unit. */ + fsWatchFile: rule({ fns: ["fs.watchFile", "fs.unwatchFile"] }), /** A socket 'lookup' listener: the C main installs the net unit's * lookup records (scr_net_lookup_install). */ netLookup: rule({ fns: ["net.sockOnLookup"] }), @@ -1286,6 +1289,7 @@ function noRuntimeFeatures(): RuntimeFeatures { fetch: false, fileHandle: false, fsWatch: false, + fsWatchFile: false, http2: false, http: false, inspect: false, @@ -1631,6 +1635,15 @@ export function moduleUsesFsWatch(mod: IrModule): boolean { return scanRuntimeFeatures(mod, "fsWatch").fsWatch; } +/** True when the module contains an fs.watchFile/fs.unwatchFile libCall — + * the link switch that pulls scr_watch_file.c into the binary and has the + * emitted main call scr_watch_file_install (the moduleUsesFsWatch + * precedent, one unit of its own). Programs that never poll pay zero bytes + * and keep their exact link line. */ +export function moduleUsesFsWatchFile(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "fsWatchFile").fsWatchFile; +} + /** True when the module contains any test.* libCall or a testCtx handle * type — the link switch that pulls scr_test.c into the binary and has * the emitted main return scr_test_exit_code() after the loop drains diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..f5312713c 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4187,7 +4187,14 @@ function validateFunction( e.fn === "readable.unpipe" || e.fn === "rl.create" || e.fn === "rl.createSignal"; - if (variadic ? e.args.length < sig.argTypes.length : e.args.length !== sig.argTypes.length) { + // fs.unwatchFile's listener is optional, so its last slot may be + // omitted (1 or 2 args) rather than extended. + const optionalTail = e.fn === "fs.unwatchFile" ? 1 : 0; + const arityOk = variadic + ? e.args.length >= sig.argTypes.length + : e.args.length === sig.argTypes.length || + (optionalTail > 0 && e.args.length === sig.argTypes.length - optionalTail); + if (!arityOk) { err(`libCall ${e.fn}: ${e.args.length} args, expected ${sig.argTypes.length}`, e.loc); } e.args.forEach((a, i) => { diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts index bc33e683a..5c8a91022 100644 --- a/packages/compiler/src/native/cli.ts +++ b/packages/compiler/src/native/cli.ts @@ -194,6 +194,7 @@ async function main(): Promise { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index bf496aa99..009818cd7 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -268,6 +268,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures { "dgramDyn", "textCodecDyn", "watch", + "watchFile", "foreignFfi", "nodeTest", "tls", diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index b1d03b52d..509602d64 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -66,6 +66,7 @@ const features = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json new file mode 100644 index 000000000..88ec75941 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-watch-file-names.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json new file mode 100644 index 000000000..7f11fb9bc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-watch-file-polling.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..d4dff88c3 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -177,6 +177,7 @@ export const OPTIONAL_RUNTIME_SOURCES = [ // TextEncoder and TextDecoder records boxed as untyped values. ["scr_text_codec_dyn.c", "textCodecDyn"], ["scr_watch.c", "watch"], + ["scr_watch_file.c", "watchFile"], ["scr_ffi_queue.c", "foreignFfi"], ["scr_test.c", "nodeTest"], ["scr_tls_ca.c", "tlsCaEffective"], diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..8a5f46a10 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5557,7 +5557,7 @@ static bool scr_stats_link_target(HANDLE h, DWORD tag, double *size_out, ScrStr return true; } -static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, +static ScrStats *scr_stats_win_handle_stats(const ScrStr *path, const char *op, bool no_follow) { WCHAR *wide = scr_fs_win_wide(path); if (!wide) return scr_stats_crt_fallback(path, op); @@ -5603,7 +5603,7 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, /* Reparse points are a general interception mechanism. Node/libuv * follows non-link tags even for lstat, so reopen the resolved target. */ CloseHandle(h); - return scr_stats_of_path(path, op, false); + return scr_stats_win_handle_stats(path, op, false); } if (!scr_stats_link_target(h, tagged.ReparseTag, &link_size, NULL)) { /* A mount-point tag may name a mounted volume rather than a junction. @@ -5611,7 +5611,7 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, * ordinary reparse points and retries with the final component * followed. Do not classify from the tag alone. */ CloseHandle(h); - return scr_stats_of_path(path, op, false); + return scr_stats_win_handle_stats(path, op, false); } is_link = true; } @@ -6054,7 +6054,7 @@ ScrBytes *scr_fs_readlink_bytes(ScrStr *path) { ScrStats *scr_fs_stat(ScrStr *path) { if (!scr_fs_path_ok(path, "path")) return NULL; #ifdef _WIN32 - return scr_stats_of_path(path, "stat", false); + return scr_stats_win_handle_stats(path, "stat", false); #else int error = 0; ScrStats *s = scr_stats_posix(path->data, -1, false, &error); /* follows symlinks */ @@ -6066,7 +6066,7 @@ ScrStats *scr_fs_stat(ScrStr *path) { ScrStats *scr_fs_lstat(ScrStr *path) { if (!scr_fs_path_ok(path, "path")) return NULL; #ifdef _WIN32 - return scr_stats_of_path(path, "lstat", true); + return scr_stats_win_handle_stats(path, "lstat", true); #else int error = 0; ScrStats *s = scr_stats_posix(path->data, -1, true, &error); /* no follow */ @@ -6108,6 +6108,33 @@ ScrStats *scr_fs_fstat(double fd) { return s; } +/* fs.watchFile's poll (scr_watch_file.c calls this): stat(2) following + * symlinks, libuv's uv_fs_stat — NULL with *error = errno on failure, + * never throwing, because a failed poll IS a reportable event. +1. */ +ScrStats *scr_stats_of_path(ScrStr *path, int *error) { + if (!scr_fs_path_ok(path, "path")) { + *error = EINVAL; + return NULL; + } +#ifdef _WIN32 + /* The CRT snapshot states what the probe can see without opening a + * handle: the poll needs the errno, not Win32's extended attributes. */ + struct stat st; + if (stat(path->data, &st) != 0) { + *error = errno; + return NULL; + } + return scr_stats_of_crt(&st); +#else + return scr_stats_posix(path->data, -1, false, error); +#endif +} + +/* The all-zero Stats a failed poll reports (Node's kZeroStats): every + * field 0, so isFile()/isDirectory() and the size/date reads all answer + * zero/false. +1. */ +ScrStats *scr_stats_zero(void) { return scr_stats_new(); } + /* ── Stats objects ─────────────────────────────────────────────────── * Node's Stats and BigIntStats over a snapshot. The integers and the *Ms * fields (and BigIntStats' *Ns fields) are own properties in the binding's diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..1a099c84d 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -479,6 +479,11 @@ double scr_stats_ctime(ScrStats *s); double scr_stats_birthtime(ScrStats *s); /* fstat without throwing: +1, or NULL with *error set to the errno. */ ScrStats *scr_stats_of_fd(int fd, int *error); +/* stat(2) following symlinks without throwing (the fs.watchFile poll): + * +1, or NULL with *error set to the errno. */ +ScrStats *scr_stats_of_path(ScrStr *path, int *error); +/* The all-zero snapshot a failed poll reports (Node's kZeroStats). +1. */ +ScrStats *scr_stats_zero(void); /* Node's Stats (or BigIntStats) object for the snapshot: own fields in the * binding's order, lazy Date accessors and the type tests on the * prototypes. Borrows s; +1. */ diff --git a/packages/runtime/src/scr_watch.h b/packages/runtime/src/scr_watch.h index c2118d1cd..83f02c917 100644 --- a/packages/runtime/src/scr_watch.h +++ b/packages/runtime/src/scr_watch.h @@ -50,6 +50,25 @@ void scr_watch_thunk0(ScrClosure *cb, const char *event, const char *filename); void scr_watch_thunk_event(ScrClosure *cb, const char *event, const char *filename); void scr_watch_err_thunk0(ScrClosure *cb, ScrError *err); void scr_watch_install(void); + +/* ── fs.watchFile (scr_watch_file.c — same gating precedent: linked only + * when the program uses it). Stat polling in Node's shape: one poller per + * RESOLVED path, shared by every listener on it. */ +typedef void (*ScrWatchFileFn)(ScrClosure *cb, ScrStats *current, ScrStats *previous); +/* fs.watchFile(path, { interval, persistent }, listener) — cb MOVES in; + * the adapter calls the listener with (current, previous) Stats (+1 each). + * The path is resolved (path.resolve) and keys the poller; a second call + * on the same resolved path adds the listener and ignores its options. */ +void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb, + ScrWatchFileFn fn); +/* fs.unwatchFile(path[, listener]) — cb borrowed, NULL removes every + * listener; with none left the poller stops. */ +void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb); +void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous); +void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous); +void scr_watch_file_thunk2(ScrClosure *cb, ScrStats *current, ScrStats *previous); +void scr_watch_file_install(void); + /* The loop-side registration (scr_async.c, always linked) — the net * hook's exact shape, one more nullable slot set. */ void scr_loop_set_watch(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)); diff --git a/packages/runtime/src/scr_watch_file.c b/packages/runtime/src/scr_watch_file.c new file mode 100644 index 000000000..6b40e5ed0 --- /dev/null +++ b/packages/runtime/src/scr_watch_file.c @@ -0,0 +1,331 @@ +/* fs.watchFile / fs.unwatchFile — the stat-polling half of the OPTIONAL + * file-watching surface: one libuv-shaped poller (uv_fs_poll) per RESOLVED + * path, shared by every listener registered on it, re-arming a one-shot + * timer every `interval` ms. This translation unit links ONLY into binaries + * whose IR uses fs.watchFile/fs.unwatchFile (the scr_watch.c gating + * precedent), and the emitted main calls scr_watch_file_install() before + * %main, which registers the exit cleanup. Polling-free programs pay zero + * bytes and keep their exact link line. + * + * Semantics, matched against Node (libuv's poll_cb + Node's StatWatcher) + * and pinned by corpus: + * - The FIRST poll only records the baseline: a successful first stat + * emits nothing (libuv's busy_polling == 0), a failing first stat emits + * ONE change with both sides all-zero (Node's kZeroStats). + * - Thereafter a stat failure emits once per distinct errno; a success + * after a failure, or a success whose snapshot differs (ctime, mtime, + * birthtime, size, mode, uid, gid, ino, dev), emits (current, + * previous), where previous is the last SUCCESSFUL snapshot. + * - Node's onchange suppression: with no status reported yet, a status of + * -1 whose nlink matches the previous snapshot reports nothing. + * - watchFile on a path that already has a poller ADDS the listener and + * ignores its options; the first call's interval and persistent stand. + * - unwatchFile(path, listener) removes the LAST listener registered with + * that closure (pointer identity, Node's rule); unwatchFile(path) + * removes every listener. The last removal stops the poller (its timer + * clears), which is what lets the process exit. + * - An emit works on a snapshot of the listeners with retained closures, + * so a listener may unwatch itself or others mid-emit — the corpus's + * own shape. + * - persistent: false unrefs the poll timer: the poller still fires while + * the loop runs for something else, but never holds the process open. */ +#include "scr_runtime.h" + +#include +#include +#include +#include + +static void scr_watch_file_oom(void) { + fputs("scriptc: out of memory\n", stderr); + abort(); +} + +/* One registered listener: the closure (owned) and its (current, + * previous) adapter. */ +typedef struct { + ScrClosure *cb; /* owned */ + ScrWatchFileFn fn; +} ScrWatchFileL; + +struct ScrWatchFiler { + size_t rc; + ScrStr *path; /* the resolved path (+1): the registry key and the stat target */ + double interval; /* the first call's interval, reused for every poll */ + bool persistent; + int state; /* libuv's busy_polling: 0 before the first poll, 1 after + * a success, -errno after a failure */ + int node_status; /* Node's kOldStatus: -1 until a change is reported */ + ScrStats *last; /* the last successful snapshot (+1), NULL = none yet */ + ScrWatchFileL *ls; + size_t n, cap; + double timer; /* the armed poll timer's handle; 0 when none is armed */ + ScrClosure *pollCb; /* the re-armed poll closure (+1); NULL once stopped */ + struct ScrWatchFiler *next; /* the poller registry (+1 each) */ +}; + +typedef struct ScrWatchFiler ScrWatchFiler; + +static SCR_TL ScrWatchFiler *scr_watch_filers = NULL; /* the registry (+1 each) */ + +static ScrWatchFiler *scr_watch_file_retain(ScrWatchFiler *p) { + if (p->rc != SIZE_MAX) p->rc++; + return p; +} + +static void *scr_watch_file_retain_v(void *p) { return scr_watch_file_retain((ScrWatchFiler *)p); } + +static void scr_watch_file_release(ScrWatchFiler *p) { + if (!p || p->rc == SIZE_MAX) return; + if (--p->rc == 0) { + for (size_t i = 0; i < p->n; i++) scr_closure_release(p->ls[i].cb); + free(p->ls); + if (p->timer != 0) { + scr_clear_interval(p->timer); + p->timer = 0; + } + scr_closure_release(p->pollCb); + scr_stats_release(p->last); + scr_str_release(p->path); + free(p); + } +} + +static void scr_watch_file_release_v(void *p) { scr_watch_file_release((ScrWatchFiler *)p); } + +/* path.resolve over the one argument: the poller's key AND the stat target, + * so `./x` and `/abs/x` share one poller, exactly like Node's + * resolve-and-match. */ +static ScrStr *scr_watch_file_resolve(ScrStr *path) { + ScrArr *parts = scr_arr_new(SCR_ELEM_STR, 1); + scr_arr_push_ref(parts, scr_str_retain(path)); + ScrStr *resolved = scr_path_resolve(parts); + scr_arr_release(parts); + return resolved; +} + +static ScrWatchFiler *scr_watch_file_find(ScrStr *resolved) { + for (ScrWatchFiler *p = scr_watch_filers; p != NULL; p = p->next) { + if (scr_str_eq(p->path, resolved)) return p; + } + return NULL; +} + +/* Arms the next poll: the first one at 0 (libuv stats on a later loop + * turn, never synchronously), then one per interval. */ +static void scr_watch_file_arm(ScrWatchFiler *p, double ms) { + p->timer = scr_set_timeout_handle(scr_closure_retain(p->pollCb), ms); + if (!p->persistent) scr_timer_unref(p->timer); +} + +/* libuv's poll comparison: ctime, mtime and birthtime (the *_ms doubles + * carry the nanosecond fraction, so a sub-millisecond change still + * differs), size, mode, uid, gid, ino and dev. `uv_stat_t`'s flags and gen + * have no ScrStats field, so they are not compared (libuv does). A NULL + * side (no successful snapshot yet) never compares equal. */ +static bool scr_stats_same(ScrStats *a, ScrStats *b) { + if (a == NULL || b == NULL) return false; + return scr_stats_ctime_ms(a) == scr_stats_ctime_ms(b) && + scr_stats_mtime_ms(a) == scr_stats_mtime_ms(b) && + scr_stats_birthtime_ms(a) == scr_stats_birthtime_ms(b) && + scr_stats_size(a) == scr_stats_size(b) && scr_stats_mode(a) == scr_stats_mode(b) && + scr_stats_uid(a) == scr_stats_uid(b) && scr_stats_gid(a) == scr_stats_gid(b) && + scr_stats_ino(a) == scr_stats_ino(b) && scr_stats_dev(a) == scr_stats_dev(b); +} + +/* Emits one change: `cur` is the fresh snapshot (NULL = all-zero), + * `prev` the last successful one (NULL = all-zero), `new_status` Node's + * status for this poll (0 success, -errno failure). Listeners run in + * registration order off a snapshot, each with its own +1 Stats refs. */ +static void scr_watch_file_emit(ScrWatchFiler *p, ScrStats *cur, ScrStats *prev, int new_status) { + double ncur = cur != NULL ? scr_stats_nlink(cur) : 0; + double nprev = prev != NULL ? scr_stats_nlink(prev) : 0; + if (p->node_status == -1 && new_status == -1 && ncur == nprev) return; + p->node_status = new_status; + if (p->n == 0) return; + ScrStats *c = cur != NULL ? scr_stats_retain(cur) : scr_stats_zero(); + ScrStats *v = prev != NULL ? scr_stats_retain(prev) : scr_stats_zero(); + ScrWatchFileL *snap = malloc(p->n * sizeof *snap); + if (!snap) { + scr_stats_release(c); + scr_stats_release(v); + scr_watch_file_oom(); + } + memcpy(snap, p->ls, p->n * sizeof *snap); + size_t n = p->n; + for (size_t i = 0; i < n; i++) scr_closure_retain(snap[i].cb); + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) snap[i].fn(snap[i].cb, scr_stats_retain(c), scr_stats_retain(v)); + scr_closure_release(snap[i].cb); + } + free(snap); + scr_stats_release(c); + scr_stats_release(v); +} + +/* The timer's body: stat, decide (libuv's poll_cb then Node's onchange), + * and re-arm unless a listener stopped the poller. */ +static void scr_watch_file_poll(ScrClosure *cb) { + ScrWatchFiler *p = scr_box_get_ref(cb->caps[0]); + if (p == NULL) return; + p->timer = 0; /* the loop consumed this arm */ + int err = 0; + ScrStats *cur = scr_stats_of_path(p->path, &err); + if (cur == NULL) { + if (p->state != -err) { + scr_watch_file_emit(p, NULL, p->last, -err); + p->state = -err; + } + } else { + if (p->state != 0 && (p->state < 0 || !scr_stats_same(p->last, cur))) + scr_watch_file_emit(p, cur, p->last, 0); + scr_stats_release(p->last); + p->last = scr_stats_retain(cur); + p->state = 1; + } + scr_stats_release(cur); /* NULL-tolerant */ + /* A listener may have unwatched the last listener (unwatchFile stops + * the poller): only a poller with listeners re-arms. */ + if (p->n > 0 && p->pollCb != NULL && !scr_exc_pending()) scr_watch_file_arm(p, p->interval); + scr_watch_file_release(p); +} + +static void scr_watch_file_add(ScrWatchFiler *p, ScrClosure *cb /*moves*/, ScrWatchFileFn fn) { + if (p->n == p->cap) { + size_t cap = p->cap != 0 ? p->cap * 2 : 4; + ScrWatchFileL *grown = realloc(p->ls, cap * sizeof *grown); + if (!grown) { + scr_closure_release(cb); + scr_watch_file_oom(); + } + p->ls = grown; + p->cap = cap; + } + p->ls[p->n].cb = cb; + p->ls[p->n].fn = fn; + p->n++; +} + +/* Stops a poller: clears its timer, unlinks it from the registry, and + * breaks the poll-closure cycle (closure → box → poller) so the last + * external reference frees the poller. Safe from inside a listener: the + * poll frame's own reference keeps `p` alive. */ +static void scr_watch_file_stop(ScrWatchFiler *p) { + scr_watch_file_retain(p); /* the frame's reference */ + if (p->timer != 0) { + scr_clear_interval(p->timer); + p->timer = 0; + } + for (ScrWatchFiler **pp = &scr_watch_filers; *pp != NULL; pp = &(*pp)->next) { + if (*pp == p) { + *pp = p->next; + p->next = NULL; + scr_watch_file_release(p); /* the registry's reference */ + break; + } + } + ScrClosure *pollCb = p->pollCb; + p->pollCb = NULL; + scr_closure_release(pollCb); /* drops the box's reference */ + scr_watch_file_release(p); +} + +/* fs.watchFile(path, interval, persistent, listener) — the listener MOVES + * in. A second call on the same resolved path adds the listener to the + * existing poller and ignores its options (Node's rule). */ +void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb, + ScrWatchFileFn fn) { + ScrStr *resolved = scr_watch_file_resolve(path); + ScrWatchFiler *existing = scr_watch_file_find(resolved); + if (existing != NULL) { + scr_str_release(resolved); + scr_watch_file_add(existing, cb, fn); + return; + } + ScrWatchFiler *p = malloc(sizeof *p); + if (!p) { + scr_str_release(resolved); + scr_closure_release(cb); + scr_watch_file_oom(); + } + p->rc = 1; + p->path = resolved; + p->interval = interval; + p->persistent = persistent; + p->state = 0; + p->node_status = -1; + p->last = NULL; + p->ls = NULL; + p->n = p->cap = 0; + p->timer = 0; + /* The poll closure captures the poller through a box, which retains it + * from here on (the cycle scr_watch_file_stop breaks). */ + p->pollCb = scr_closure_new((void *)scr_watch_file_poll, 1); + p->pollCb->caps[0] = + scr_box_new_obj(scr_watch_file_retain_v, scr_watch_file_release_v, NULL); + scr_box_set_ref(p->pollCb->caps[0], p); + scr_watch_file_add(p, cb, fn); + p->next = scr_watch_filers; + scr_watch_filers = p; + scr_watch_file_retain(p); /* the registry's reference */ + scr_watch_file_arm(p, 0); + /* The creating reference moved into the box (scr_box_set_ref moves, it + * does not retain): the box and the registry are the two owners. */ +} + +/* fs.unwatchFile(path[, listener]) — `cb` is borrowed (pointer identity); + * NULL removes every listener on the resolved path. Removing the last one + * stops the poller. */ +void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) { + ScrStr *resolved = scr_watch_file_resolve(path); + ScrWatchFiler *p = scr_watch_file_find(resolved); + scr_str_release(resolved); + if (p == NULL) return; + if (cb != NULL) { + /* The LAST listener registered with this closure goes (Node's rule). */ + for (size_t i = p->n; i-- > 0;) { + if (p->ls[i].cb == cb) { + scr_closure_release(p->ls[i].cb); + memmove(&p->ls[i], &p->ls[i + 1], (p->n - i - 1) * sizeof *p->ls); + p->n--; + break; + } + } + } else { + for (size_t i = 0; i < p->n; i++) scr_closure_release(p->ls[i].cb); + p->n = 0; + } + if (p->n == 0) scr_watch_file_stop(p); +} + +/* ── the runtime-provided listener adapters ─────────────────────────── + * The listener owns its +1 arguments (the universal convention + * scr_watch_thunk_event follows). */ +void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous) { + (void)current; + (void)previous; + ((void (*)(ScrClosure *))cb->fn)(cb); +} + +void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous) { + (void)previous; + ((void (*)(ScrClosure *, ScrStats *))cb->fn)(cb, current); +} + +void scr_watch_file_thunk2(ScrClosure *cb, ScrStats *current, ScrStats *previous) { + ((void (*)(ScrClosure *, ScrStats *, ScrStats *))cb->fn)(cb, current, previous); +} + +/* Exit-time registry cleanup (the scr_watch.c precedent): pollers a program + * legitimately leaves running at exit release their listeners and timers, + * so the RC audit sees a clean heap. */ +static void scr_watch_file_cleanup_atexit(void) { + while (scr_watch_filers != NULL) scr_watch_file_stop(scr_watch_filers); +} + +void scr_watch_file_install(void) { + static SCR_TL bool installed = false; + if (installed) return; + installed = true; + scr_atexit(scr_watch_file_cleanup_atexit); +} diff --git a/tests/corpus/fs-watch-file-names.ts b/tests/corpus/fs-watch-file-names.ts new file mode 100644 index 000000000..61c824332 --- /dev/null +++ b/tests/corpus/fs-watch-file-names.ts @@ -0,0 +1,60 @@ +// fs.watch listeners that take the filename, on a FILE watch: the +// filename is the watched file's basename. The listener can be a runtime +// value typed WatchListener, and the watcher's EventEmitter +// surface takes more 'change' listeners (on), a one-shot one (once), an +// 'error' listener (never called here), and a 'close' listener, which +// runs on the next tick after close() (before immediates) and only once +// however often close() is called. Triggers repeat on an interval until +// the event lands; event counts and coalescing are deliberately +// unobserved (they differ between inotify, kqueue and FSEvents). +import * as fs from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const dir = fs.mkdtempSync(join(tmpdir(), "scr-watch-names-")); +const head = join(dir, "HEAD"); +fs.writeFileSync(head, "ref: main\n"); + +let firstName: string | null = "unset"; +let secondName = "unset"; +let onceCalls = 0; +const listener: fs.WatchListener = (eventType, filename) => { + if (firstName !== "unset") return; + firstName = filename; + console.log("listener value names", filename, eventType === "change" || eventType === "rename"); +}; +const watcher = fs.watch(head, listener); +// 'change' listener named as any: the @types/node overload declares +// filename string | Buffer, whose bytes arm has no lowering (Node passes a +// string with the default encoding), so the checked name arrives as dyn. +watcher.on("change", (_eventType: string, filename: any) => { + if (secondName !== "unset") return; + secondName = String(filename); + console.log("on('change') names", secondName); +}); +watcher.once("change", () => { + onceCalls++; +}); +watcher.on("error", (error: Error) => console.log("error", error.message)); +watcher.on("close", () => { + console.log("close event", onceCalls); + setImmediate(() => { + console.log("immediate after close"); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +const bound = setTimeout(() => { + console.log("timed out"); + process.exit(1); +}, 10000); +const touch = setInterval(() => fs.appendFileSync(head, "x"), 40); +const poll = setInterval(() => { + if (firstName === "unset" || secondName === "unset") return; + clearInterval(poll); + clearInterval(touch); + clearTimeout(bound); + watcher.close(); + watcher.close(); + console.log("closed"); +}, 10); diff --git a/tests/corpus/fs-watch-file-polling.ts b/tests/corpus/fs-watch-file-polling.ts new file mode 100644 index 000000000..87cddf867 --- /dev/null +++ b/tests/corpus/fs-watch-file-polling.ts @@ -0,0 +1,49 @@ +// fs.watchFile / fs.unwatchFile: stat polling at the given interval. The +// listener receives (current, previous) Stats whenever a poll sees a +// difference; the first poll only records the baseline. A missing file +// polls as all-zero Stats: the first poll reports one change with both +// sides zeroed, and the creation reports previous.size 0 and previous.mtimeMs 0. Two watchFile calls on one path share one poller; +// unwatchFile(path, listener) removes one listener, unwatchFile(path) +// removes the rest, and once no listener is left the poller stops and +// the process can exit. +import * as fs from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const dir = fs.mkdtempSync(join(tmpdir(), "scr-watchfile-")); +const existing = join(dir, "existing.txt"); +const later = join(dir, "later.txt"); +fs.writeFileSync(existing, "a"); + +let firstCalls = 0; +const first = (current: fs.Stats, previous: fs.Stats): void => { + firstCalls++; + if (firstCalls === 1) console.log("existing grew", previous.size, "->", current.size); +}; +const second = (current: fs.Stats, previous: fs.Stats): void => { + console.log("second listener", previous.size, "->", current.size, current.isFile()); + fs.unwatchFile(existing, first); + fs.unwatchFile(existing); + startMissingPhase(); +}; +fs.watchFile(existing, { interval: 20 }, first); +fs.watchFile(existing, { interval: 1000 }, second); +setTimeout(() => fs.writeFileSync(existing, "abcd"), 150); + +function startMissingPhase(): void { + let events = 0; + const listener = (current: fs.Stats, previous: fs.Stats): void => { + events++; + if (events === 1) { + // The first poll fails (ENOENT): one change with both sides zeroed. + console.log("missing", previous.size, previous.mtimeMs, previous.isFile(), current.size, current.isFile()); + setTimeout(() => fs.writeFileSync(later, "hello"), 100); + return; + } + console.log("created", previous.size, previous.mtimeMs, "->", current.size, current.isFile()); + fs.unwatchFile(later, listener); + fs.rmSync(dir, { recursive: true, force: true }); + console.log("unwatched"); + }; + fs.watchFile(later, { interval: 20, persistent: true }, listener); +} diff --git a/tests/harness/corpus-costs/fs-watch-file-names.ts.json b/tests/harness/corpus-costs/fs-watch-file-names.ts.json new file mode 100644 index 000000000..91f0b7565 --- /dev/null +++ b/tests/harness/corpus-costs/fs-watch-file-names.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 3300 } diff --git a/tests/harness/corpus-costs/fs-watch-file-polling.ts.json b/tests/harness/corpus-costs/fs-watch-file-polling.ts.json new file mode 100644 index 000000000..376e9a4e6 --- /dev/null +++ b/tests/harness/corpus-costs/fs-watch-file-polling.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 3800 } From 52c6dccfa5279b8ea16c4a498cbc51fbe386f15c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:00:02 -0500 Subject: [PATCH 028/193] Read the callee of an untyped member call before its arguments A call through an untyped receiver now reads a class instance's field, a getter, or a property added to the instance before it evaluates the arguments, as JavaScript does. An argument that replaces the member no longer changes which function runs. --- .../lowering/class-dynamic-dispatch.ts | 156 ++++++++++++++++++ .../corpus/dynamic-call-callee-order.mjs.json | 6 + packages/runtime/src/scr_dyn.h | 6 + packages/runtime/src/scr_dyn_invoke.c | 8 + packages/runtime/src/scr_json.c | 46 ++++++ tests/corpus/dynamic-call-callee-order.mjs | 113 +++++++++++++ 6 files changed, 335 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json create mode 100644 tests/corpus/dynamic-call-callee-order.mjs diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index c19763e8d..1870f87ce 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -93,6 +93,96 @@ import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js"; import { STREAM_OPERATORS, operatorSignalOptions, reduceSignalSetup } from "./lower-stream.js"; import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js"; +/** Methods that builtin prototypes define (arrays, strings, collections, + * promises, functions, regular expressions, iterators, Object.prototype): + * a call of one of these names through an untyped receiver keeps the + * runtime's method dispatch, which answers them, even when a class + * declares a getter of the same name. */ +const BUILTIN_METHOD_NAMES: ReadonlySet = new Set([ + "add", + "apply", + "at", + "bind", + "call", + "catch", + "charAt", + "charCodeAt", + "clear", + "codePointAt", + "concat", + "copyWithin", + "delete", + "endsWith", + "entries", + "every", + "exec", + "fill", + "filter", + "finally", + "find", + "findIndex", + "findLast", + "findLastIndex", + "flat", + "flatMap", + "forEach", + "get", + "has", + "hasOwnProperty", + "includes", + "indexOf", + "isPrototypeOf", + "join", + "keys", + "lastIndexOf", + "localeCompare", + "map", + "match", + "matchAll", + "next", + "normalize", + "padEnd", + "padStart", + "pop", + "propertyIsEnumerable", + "push", + "reduce", + "reduceRight", + "repeat", + "replace", + "replaceAll", + "return", + "reverse", + "search", + "set", + "shift", + "slice", + "some", + "sort", + "splice", + "split", + "startsWith", + "substr", + "substring", + "test", + "then", + "throw", + "toLocaleString", + "toLowerCase", + "toReversed", + "toSorted", + "toSpliced", + "toString", + "toUpperCase", + "trim", + "trimEnd", + "trimStart", + "unshift", + "valueOf", + "values", + "with", +]); + const STREAM_METHODS = new Set([ "on", "addListener", @@ -1897,6 +1987,64 @@ export class ClassDynamicDispatch { byMethod.set(method, matching); return matching; }; + // A name some class declares as a getter, and no class as a method: the + // call reads the member (running the getter) before it evaluates the + // arguments, as JavaScript reads the callee first, and then calls what + // it read. The runtime reads fields and other own properties of class + // instances that early itself (scr_dyn_prepare_method). + const getterReceiver = (method: string): boolean => + !BUILTIN_METHOD_NAMES.has(method) && + this.propertyReceivers(lowerer, true).some(({ info }) => + findMethodOn(lowerer, info, `get:${method}`), + ); + const getterCall = (fn: IrFunction, invoke: IrExpr & { kind: "dynInvoke" }): IrExpr => { + const loc = invoke.loc; + const receiverLocal = { + id: `%dispatch.receiver.${fn.locals.length}`, + name: "receiver", + type: DYN, + mutable: false, + }; + fn.locals.push(receiverLocal); + const calleeLocal = { + id: `%dispatch.callee.${fn.locals.length}`, + name: "callee", + type: DYN, + mutable: false, + }; + fn.locals.push(calleeLocal); + const receiver = varRef(receiverLocal.id, DYN, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiverLocal.id, init: invoke.recv, loc }, + { + kind: "varDecl", + localId: calleeLocal.id, + init: { + kind: "dynKeyGet", + key: { kind: "strLit", value: invoke.method, type: STRING, loc }, + value: receiver, + type: DYN, + loc, + }, + loc, + }, + ], + result: { + kind: "dynCall", + callee: varRef(calleeLocal.id, DYN, loc), + receiver, + calleeName: invoke.calleeName, + ...(invoke.calleeNameValue ? { calleeNameValue: invoke.calleeNameValue } : {}), + args: invoke.args, + type: DYN, + loc, + }, + type: DYN, + loc, + }; + }; const candidateSets = new Map>(); const candidateSet = (method: string): ReadonlySet => { let set = candidateSets.get(method); @@ -2964,6 +3112,14 @@ export class ClassDynamicDispatch { const info = lowerer.classes.get(name); return info && findStaticOn(lowerer, info, method)?.method; }); + if ( + invoke && + expr.kind === "dynInvoke" && + candidates(invoke.method).length === 0 && + !staticCandidates(invoke.method) && + getterReceiver(invoke.method) + ) + return getterCall(fn, expr); if ( !invoke || (candidates(invoke.method).length === 0 && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json new file mode 100644 index 000000000..1e72aaf98 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-call-callee-order.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 343930580..a995002d5 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1411,6 +1411,12 @@ void scr_dyn_typed_ref_bind_bag_class(ScrDyn *d, const ScrDynTypedBagClassOps *o * exception pending, when the prototype hook threw. Never runs user * code. */ ScrDyn *scr_dyn_typed_ref_bag_method(const ScrDyn *d, const char *key, size_t key_len); +/* The own data property `key` of a class instance's capsule, +1, as its + * view holds it; NULL when the instance has no such own data property, or + * when `key` names a member of its class's prototype or of + * Object.prototype. NULL with an exception pending when building the view + * threw. */ +ScrDyn *scr_dyn_typed_ref_own_member(const ScrDyn *d, const char *key, size_t key_len); /* The constructor name of a class instance capsule's own class, for * util.inspect; NULL when the capsule is not a bound class instance, its * class is anonymous, or its prototype was replaced. */ diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 7a9b1bc35..0279ccab1 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -787,6 +787,14 @@ ScrDyn *scr_dyn_prepare_method(ScrDyn *recv, const char *method) { scr_str_release(key); return result; } + /* A class instance's own function-valued property is read before the + * arguments are evaluated, as JavaScript reads the callee first; its + * methods keep the dispatch scr_dyn_invoke makes. */ + if (recv->kind == SCR_DYN_TYPED_REF) { + ScrDyn *own = scr_dyn_typed_ref_own_member(recv, method, strlen(method)); + if (own && scr_dyn_is_callable(own)) return own; + scr_dyn_release(own); + } return NULL; } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f3b2d3dc0..0b4ffb2a1 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4761,6 +4761,52 @@ bool scr_dyn_typed_ref_class_has(const ScrDyn *d, const ScrStr *key) { return ops && scr_dyn_name_in_strs(key, ops->names, ops->count); } +ScrDyn *scr_dyn_typed_ref_own_member(const ScrDyn *d, const char *key, size_t key_len) { + if (d->kind != SCR_DYN_TYPED_REF || d->prototype) return NULL; + if (!d->v.typed_ref.bag_class) { + /* A class instance's view holds its own fields and the properties added + * to it, and its class's prototype members stay off the view. A name + * that is neither one of those members nor one of Object.prototype's + * can only be an own property, so the view is built (refreshed from the + * instance) for it. Other capsules answer through scr_dyn_invoke. */ + const ScrDynTypedClassOps *klass = scr_typed_ref_class_ops(d); + if (!klass) return NULL; + ScrStr *name = scr_str_new(key, key_len); + bool member = scr_dyn_name_in_strs(name, klass->names, klass->count) || scr_dyn_object_proto_name(name); + scr_str_release(name); + if (member) return NULL; + ScrDyn *view = scr_dyn_typed_ref_materialize(d); + if (scr_exc_pending()) { + scr_dyn_release(view); + return NULL; + } + ScrDyn *found = NULL; + if (view && view->kind == SCR_DYN_OBJ) { + if (scr_dyn_is_layout(view)) { + ScrDyn **slot = scr_dyn_layout_slot(view, key, key_len); + if (slot) found = scr_dyn_retain(*slot); + } else { + const ScrDynEntry *own = scr_dyn_find_entry(view, key, key_len); + if (own && !own->accessor) found = scr_dyn_retain(own->value); + } + } + scr_dyn_release(view); + return found; + } + const ScrDynTypedBagClassOps *ops = (const ScrDynTypedBagClassOps *)(const void *)d->v.typed_ref.array; + void *instance = d->v.typed_ref.ptr; + const ScrDyn *bag = ops->bag(instance); + if (!bag || bag->kind != SCR_DYN_OBJ) return NULL; + const ScrDynEntry *own = scr_dyn_find_entry(bag, key, key_len); + if (!own || own->accessor) return NULL; + for (size_t i = 0; i < ops->count; i++) { + const ScrStr *name = ops->names[i]; + if (name->len != key_len || memcmp(name->data, key, key_len) != 0) continue; + return own->writable ? ops->field(instance, i) : NULL; + } + return scr_dyn_retain(own->value); +} + static const ScrDynTypedRecordOps *scr_typed_ref_record_ops(const ScrDyn *d) { if (!d->v.typed_ref.record || d->v.typed_ref.materialized) return NULL; const ScrDynTypedRecordOps *ops = (const ScrDynTypedRecordOps *)(const void *)d->v.typed_ref.array; diff --git a/tests/corpus/dynamic-call-callee-order.mjs b/tests/corpus/dynamic-call-callee-order.mjs new file mode 100644 index 000000000..20190db84 --- /dev/null +++ b/tests/corpus/dynamic-call-callee-order.mjs @@ -0,0 +1,113 @@ +// A call through an untyped receiver reads its callee before it evaluates +// the arguments: an argument that replaces the member does not change which +// function the call runs. Class instance fields, properties added to an +// instance later, plain object properties, prototype methods, getters, own +// accessors, a field that does not hold a function, and array methods named +// like a class field or getter. +const log = []; +class Holder { + constructor() { + this.run = () => "field-init"; + } + get viaGetter() { + log.push("getter"); + return () => "from-getter"; + } +} +function callRun(target, replace) { + return target.run(replace()); +} +function callMethod(target, replace) { + return target.method(replace()); +} +function callGetter(target, replace) { + return target.viaGetter(replace()); +} +const holder = new Holder(); +holder.run = () => "field-old"; +console.log( + callRun(holder, () => { + holder.run = () => "field-new"; + return 0; + }), + holder.run(), +); +function callExtra(target, replace) { + return target.extra(replace()); +} +const later = new Holder(); +later.extra = () => "added-old"; +console.log( + callExtra(later, () => { + later.extra = () => "added-new"; + return 0; + }), + later.extra(), +); +const plain = { run: () => "plain-old" }; +console.log( + callRun(plain, () => { + plain.run = () => "plain-new"; + return 0; + }), + plain.run(), +); +class Swapped { + method() { + return "method-old"; + } +} +console.log( + callMethod(new Swapped(), () => { + Swapped.prototype.method = () => "method-new"; + return 0; + }), + new Swapped().method(), +); +console.log( + callGetter(new Holder(), () => { + log.push("argument"); + return 0; + }), + log.join(","), +); +log.length = 0; +const accessorTarget = { run: () => "plain" }; +Object.defineProperty(accessorTarget, "run", { + get() { + log.push("own-getter"); + return () => "own-accessor"; + }, + configurable: true, +}); +console.log( + callRun(accessorTarget, () => { + log.push("argument"); + return 0; + }), + log.join(","), +); +class Box { + constructor(value) { + this.run = value; + } +} +try { + callRun(new Box(5), () => 0); +} catch (e) { + console.log(e instanceof TypeError, e.message); +} +// A class field and a getter named like array methods do not change calls +// of those methods on arrays. +class Material { + constructor() { + this.map = null; + } + get filter() { + return () => "material-filter"; + } +} +function doubled(items) { + return items.map((x) => x * 2).filter((x) => x > 2); +} +console.log(new Material().map, new Material().filter(), doubled([1, 2, 3]).join(",")); From 7dbcf07fe03383be1f0813b11c2c70649524d6cb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:12:05 -0700 Subject: [PATCH 029/193] Pass console lines and byte chunks to a patched stdio write Node's console writes each formatted line with stream.write(line, errorHandler), so a replaced process.stdout.write sees a string and a function; a Buffer write arrives as a Buffer. The stdio write hook carried only a `wide` bit, so every intercepted chunk became a JavaScript string and console lines carried a single argument. Replace that bit with a `flags` int (SCR_STDIO_HOOK_WIDE, _CONSOLE, _BYTES) and keep the hook signature at four parameters, so the console write path and the byte paths differ only in what they pass. scr_stdio_intercept now builds a chunk instead of a string, boxes SCR_STDIO_HOOK_BYTES as a Buffer, and appends a console error handler for SCR_STDIO_HOOK_CONSOLE. Text callers of the old string entry point move to a new scr_stdio_write_text. Corpus: stdout-patch-console-arguments.js. Co-Authored-By: Claude Code Co-Authored-By: Claude 5.5 --- .../stdout-patch-console-arguments.js.json | 6 ++++ packages/runtime/src/scr_console.c | 18 +++++++--- packages/runtime/src/scr_console_native.c | 4 +-- packages/runtime/src/scr_events.c | 33 ++++++++++++++++--- packages/runtime/src/scr_lib.h | 13 ++++++-- .../corpus/stdout-patch-console-arguments.js | 29 ++++++++++++++++ .../stdout-patch-console-arguments.js.json | 1 + 7 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-console-arguments.js.json create mode 100644 tests/corpus/stdout-patch-console-arguments.js create mode 100644 tests/harness/corpus-costs/stdout-patch-console-arguments.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-console-arguments.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-console-arguments.js.json new file mode 100644 index 000000000..d8798d615 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-console-arguments.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stdout-patch-console-arguments.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index f1fefa626..040cb2234 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -203,7 +203,7 @@ void scr_init(void) { * the stream differs. */ /* A real definition lets relocatable library links localize this hook; * a tentative common symbol stays externally visible on Mach-O. */ -SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, bool wide) = NULL; +SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, int flags) = NULL; SCR_TL bool (*scr_stdio_capture)(int fd, const void *data, size_t len) = NULL; SCR_TL bool scr_stdin_fed = false; @@ -420,7 +420,7 @@ static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool glob } if (scr_stdio_write_hook) { /* A patched write receives the JavaScript string, lone surrogates kept. */ - scr_stdio_write_hook(fd, line, used, wide); + scr_stdio_write_hook(fd, line, used, (wide ? SCR_STDIO_HOOK_WIDE : 0) | SCR_STDIO_HOOK_CONSOLE); if (scr_exc_pending()) scr_exc_clear(); } else { if (wide) scr_wtf8_to_utf8(line, used); @@ -481,8 +481,16 @@ int scr_stdio_write_raw(int fd, const void *data, size_t len) { #endif } +/* A byte chunk (process.stdout.write(buffer), a stream piped to stdout). */ bool scr_stdio_write(int fd, const void *data, size_t len) { - if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, data, len, false); + if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, data, len, SCR_STDIO_HOOK_BYTES); + (void)scr_stdio_write_raw(fd, data, len); + return true; +} + +/* A string chunk that is plain UTF-8. */ +bool scr_stdio_write_text(int fd, const void *data, size_t len) { + if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, data, len, 0); (void)scr_stdio_write_raw(fd, data, len); return true; } @@ -490,7 +498,7 @@ bool scr_stdio_write(int fd, const void *data, size_t len) { /* A string chunk: a patched write receives the string itself; the stream * receives its UTF-8, each lone surrogate as U+FFFD. */ static __attribute__((noinline)) bool scr_stdio_write_wide(int fd, const ScrStr *s) { - if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, s->data, s->len, true); + if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, s->data, s->len, SCR_STDIO_HOOK_WIDE); char *owned; const char *bytes = scr_str_utf8(s, &owned); (void)scr_stdio_write_raw(fd, bytes, s->len); @@ -500,5 +508,5 @@ static __attribute__((noinline)) bool scr_stdio_write_wide(int fd, const ScrStr bool scr_stdio_write_str(int fd, const ScrStr *s) { if (__builtin_expect(scr_wtf8_any(), 0) && !s->ascii) return scr_stdio_write_wide(fd, s); - return scr_stdio_write(fd, s->data, s->len); + return scr_stdio_write_text(fd, s->data, s->len); } diff --git a/packages/runtime/src/scr_console_native.c b/packages/runtime/src/scr_console_native.c index e11ffd3af..82dc476a1 100644 --- a/packages/runtime/src/scr_console_native.c +++ b/packages/runtime/src/scr_console_native.c @@ -489,8 +489,8 @@ void scr_console_clear(void) { if (!scr_process_is_tty(1)) return; const char *term = getenv("TERM"); if (term && strcmp(term, "dumb") == 0) return; - scr_stdio_write(1, "\x1b[1;1H", 6); - scr_stdio_write(1, "\x1b[0J", 4); + scr_stdio_write_text(1, "\x1b[1;1H", 6); + scr_stdio_write_text(1, "\x1b[0J", 4); } /* console.dir over a dyn value: inspect with custom inspection off. The diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 78b7bb0f1..de1d12105 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1507,9 +1507,19 @@ static ScrDyn *scr_stdio_invoke(void *ptr, ScrDyn *self, const char *name, return result; } -static bool scr_stdio_intercept(int fd, const void *data, size_t length, bool wide) { +/* Node's console error handler for a stream write (createWriteErrorHandler): + * console swallows write errors, so it ignores its argument. */ +static ScrDyn *scr_stdio_console_error_handler(ScrClosure *cb, ScrDyn *const *args, size_t argc) { + (void)cb; (void)args; (void)argc; + return scr_dyn_retain(scr_dyn_undefined()); +} + +static SCR_TL ScrDyn *scr_stdio_console_handler = NULL; + +static bool scr_stdio_intercept(int fd, const void *data, size_t length, int flags) { ScrStdio *stream = &scr_stdio_streams[fd == 2 ? 2 : 1]; if (!stream->write || scr_dyn_strict_eq(stream->write, scr_stdio_methods[STDIO_WRITE])) { + bool wide = (flags & SCR_STDIO_HOOK_WIDE) != 0; char *owned = NULL; if (wide && scr_wtf8_find_surrogate(data, length)) { owned = malloc(length ? length : 1); @@ -1522,12 +1532,22 @@ static bool scr_stdio_intercept(int fd, const void *data, size_t length, bool wi free(owned); return true; } - ScrStr *text = scr_str_new(data, length); - ScrDyn *chunk = scr_dyn_new_str(text); - scr_str_release(text); + ScrDyn *chunk; + if (flags & SCR_STDIO_HOOK_BYTES) { + ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)length); + memcpy(b->data, data, length); + chunk = scr_dyn_new_chunk(b); + scr_bytes_release(b); + } else { + ScrStr *text = scr_str_new(data, length); + chunk = scr_dyn_new_str(text); + scr_str_release(text); + } + ScrDyn *argv[2] = {chunk, scr_stdio_console_handler}; + size_t argc = (flags & SCR_STDIO_HOOK_CONSOLE) ? 2 : 1; scr_dyn_this_push(stream, SCR_DYNH_STDIO); ScrDyn *fn = scr_dyn_retain(stream->write); - ScrDyn *result = scr_dyn_call(fn, &chunk, 1, "write"); + ScrDyn *result = scr_dyn_call(fn, argv, argc, "write"); scr_dyn_release(fn); scr_dyn_this_pop(); bool accepted = result && scr_dyn_truthy(result); @@ -1562,6 +1582,8 @@ static void scr_stdio_cleanup(void) { scr_dyn_release(scr_stdio_methods[i]); scr_stdio_methods[i] = NULL; } + scr_dyn_release(scr_stdio_console_handler); + scr_stdio_console_handler = NULL; scr_stdio_initialized = false; } @@ -1582,6 +1604,7 @@ ScrDyn *scr_process_stdio(double fd) { scr_box_set_f64(cb->caps[0], i); scr_stdio_methods[i] = scr_dyn_new_func(cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", scr_stdio_names[i]); } + scr_stdio_console_handler = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_stdio_console_error_handler, 1, "", ""); scr_stdio_write_hook = scr_stdio_intercept; } return scr_dyn_new_handle(&scr_stdio_streams[fd == 0 ? 0 : fd == 2 ? 2 : 1], SCR_DYNH_STDIO); diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..324a4c041 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -71,9 +71,16 @@ ScrStr *scr_process_cwd(void); /* +1 fresh (getcwd) */ bool scr_stdio_write(int fd, const void *data, size_t len); bool scr_stdio_write_str(int fd, const ScrStr *s); int scr_stdio_write_raw(int fd, const void *data, size_t len); -/* `wide` marks string bytes that may hold lone surrogates, which become - * U+FFFD when they reach the stream itself. */ -extern SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, bool wide); +/* `flags` describes the chunk a patched write receives: SCR_STDIO_HOOK_WIDE + * marks string bytes that may hold lone surrogates (they become U+FFFD when + * they reach the stream itself), SCR_STDIO_HOOK_CONSOLE a console line + * (Node's console passes its error handler as the second argument), and + * SCR_STDIO_HOOK_BYTES a byte chunk (the replacement receives a Buffer). */ +enum { SCR_STDIO_HOOK_WIDE = 1, SCR_STDIO_HOOK_CONSOLE = 2, SCR_STDIO_HOOK_BYTES = 4 }; +extern SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, int flags); +/* A string chunk given as bytes (UTF-8, no lone surrogates): what + * scr_stdio_write was for callers that write text. */ +bool scr_stdio_write_text(int fd, const void *data, size_t len); /* A worker created with the stdout or stderr option: answers true when it * took the bytes for fd 1 or 2 instead of the process descriptor. */ extern SCR_TL bool (*scr_stdio_capture)(int fd, const void *data, size_t len); diff --git a/tests/corpus/stdout-patch-console-arguments.js b/tests/corpus/stdout-patch-console-arguments.js new file mode 100644 index 000000000..7f73d785f --- /dev/null +++ b/tests/corpus/stdout-patch-console-arguments.js @@ -0,0 +1,29 @@ +// What a replaced process.stdout.write receives from console: Node's +// console writes each formatted line with stream.write(line, errorHandler), +// so the replacement sees a string and a function as its second argument +// for console.log, info, debug, dir, count, table and grouped lines alike; +// a direct write passes exactly its own arguments. The error handler accepts +// a null error. +const original = process.stdout.write; +const seen = []; +process.stdout.write = function (chunk, encodingOrCallback, callback) { + const kind = typeof chunk === "string" ? "string" : Buffer.isBuffer(chunk) ? "Buffer" : chunk.constructor.name; + seen.push(`${kind}:${JSON.stringify(String(chunk))}:${typeof encodingOrCallback}:${typeof callback}`); + if (typeof encodingOrCallback === "function") encodingOrCallback(null); + return true; +}; +console.log("line", 1); +console.info("info"); +console.debug("debug"); +console.log("%s=%d", "x", 2); +console.dir({ a: 1 }); +console.count(); +console.group("group"); +console.log("inside"); +console.groupEnd(); +console.table([{ a: 1 }]); +process.stdout.write("direct"); +process.stdout.write("hex", "utf8"); +process.stdout.write(Buffer.from("bytes")); +process.stdout.write = original; +console.log(seen.join("\n")); diff --git a/tests/harness/corpus-costs/stdout-patch-console-arguments.js.json b/tests/harness/corpus-costs/stdout-patch-console-arguments.js.json new file mode 100644 index 000000000..b5d3a2f90 --- /dev/null +++ b/tests/harness/corpus-costs/stdout-patch-console-arguments.js.json @@ -0,0 +1 @@ +{ "plain": 5460, "san": 7830 } From 22357e8bedc5b5d2ab27c7a9573457366cec064a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:18:11 -0500 Subject: [PATCH 030/193] Lower ReadableStream tee, pipeTo and pipeThrough in TypeScript In TypeScript, tee(), pipeTo() and pipeThrough() on a ReadableStream were refused with SC2020, and the WritableStream constructor and a TransformStream with a transformer had no lowering, while JavaScript programs reach all of them through the web stream classes (scr_webstreams.c). A TypeScript program that uses one of them now builds its ReadableStream, WritableStream and TransformStream values with those classes, as JavaScript does, and %main installs them, so a native stream such as a Response body answers the three methods through its web view. A program that uses only getReader(), cancel(), locked and the identity transform keeps the native streams and builds to the same binary. The fallback declarations gain UnderlyingSink and Transformer, the WritableStream constructor takes a sink and the TransformStream constructor a transformer and queuing strategies, and their controllers map to checked-dynamic handles. The fetch profile lists the three methods as static with the new static-web-streams fetch fixture as their differential evidence, and keeps them in the set that links the classes. The surface manifest is regenerated, and the surface-manifest probe that kept tee() and pipeTo() fenced now fences values() and the async iterator. Adds the static-web-streams fixture and the corpus program web-streams-typed-pipes.ts, with its corpus cost and order baseline. --- .../builtin-apis/streams-and-readline.mdx | 6 +- .../ambient/scriptc-node-fallback.d.ts | 31 +++- packages/compiler/src/compat/fetch-profile.ts | 51 +++--- .../lowering/builtins/web-streams-dyn.ts | 85 +++++++++- .../src/frontend/lowering/lower-stdlib.ts | 7 +- .../compiler/src/frontend/lowering/lowerer.ts | 4 + packages/compiler/src/frontend/type-mapper.ts | 2 + packages/compiler/surface-manifest.json | 15 +- .../corpus/web-streams-typed-pipes.ts.json | 6 + tests/corpus/web-streams-typed-pipes.ts | 158 ++++++++++++++++++ .../fetch/static-web-streams/main.mts | 96 +++++++++++ .../__snapshots__/node-types-fenced.txt | 26 +-- .../web-streams-typed-pipes.ts.json | 1 + tests/harness/fetch.test.ts | 1 + tests/harness/surface-manifest.test.ts | 2 +- 15 files changed, 419 insertions(+), 72 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-pipes.ts.json create mode 100644 tests/corpus/web-streams-typed-pipes.ts create mode 100644 tests/fixtures/fetch/static-web-streams/main.mts create mode 100644 tests/harness/corpus-costs/web-streams-typed-pipes.ts.json diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index 52bf534e8..01cd9eb9d 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -35,7 +35,9 @@ `stream.compose` accepts streams and async generator functions in any position, an async function as the last stage, and an array, a string, or a generator as the first stage; one stage is `Duplex.from` of it. A web `ReadableStream` is read as an async iterable by `Readable.from` and as the first stage of `compose` or `pipeline`, and through its reader by `Readable.fromWeb` (with `objectMode`, `highWaterMark`, `encoding`, and `signal` in an options object literal) and `Duplex.from`. In TypeScript, `new TransformStream()` without arguments builds the identity transform: its readable side is a web `ReadableStream`, its writable side supports `getWriter`, `abort`, `close`, and `locked`, and the writer supports `write`, `close`, `abort`, `releaseLock`, `ready`, `closed`, and `desiredSize`, with Node's backpressure (a write completes when the readable side reads its chunk). - A web stream in a later compose stage and `Readable.toWeb` are unsupported, and in TypeScript, so are a `TransformStream` with a transformer or queuing strategies, the `WritableStream` constructor, `pipeTo`, and `pipeThrough`. + In TypeScript, `tee`, `pipeTo`, and `pipeThrough`, the `WritableStream` constructor with an underlying sink, and a `TransformStream` with a transformer or queuing strategies compile: a program that uses one of them builds its `ReadableStream`, `WritableStream`, and `TransformStream` values with the same classes as JavaScript, and a `Response` or `Request` body has those members too. + A TypeScript program that uses only `getReader`, `cancel`, `locked`, and the identity `TransformStream` keeps the smaller native streams. + A web stream in a later compose stage and `Readable.toWeb` are unsupported. The `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream. `readable.compose(stage, { signal })` is `stream.compose(readable, stage)` with the signal added; the options are an object literal whose `signal` is a variable or a property read. `pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, with or without function stages, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags. @@ -60,7 +62,7 @@ The `readline/promises` `Readline` class writes to the same outputs. `readline.emitKeypressEvents` emits `'keypress'` events on `process.stdin` or a readable stream, shared with terminal interfaces on that stream as in Node; a listener's first parameter must admit `undefined`, which it receives for escape sequences. Chained listener registration is unsupported. - `node:stream/web` exports the same classes as the globals; in TypeScript, only `ReadableStream` and the identity `TransformStream` have lowerings. + `node:stream/web` exports the same classes as the globals; in TypeScript, `ReadableStream`, `WritableStream`, and `TransformStream` have lowerings. In JavaScript, `ReadableStream`, `WritableStream`, and `TransformStream` with their default readers, writers, and controllers, `CountQueuingStrategy`, `ByteLengthQueuingStrategy`, `TextEncoderStream`, and `TextDecoderStream`, as globals and as `node:stream/web` exports, are constructor values with Node's prototypes, brand checks, and errors. Their underlying sources, sinks, and transformers, queuing strategies with a `size` function, readers and writers, `tee`, `pipeTo` and `pipeThrough` with their options and an abort signal, `ReadableStream.from`, and async iteration settle promises in Node's order, and the streams take own properties. `TextDecoderStream` decodes UTF-8; its other encodings are unsupported. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..7a7b61fd7 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1115,9 +1115,9 @@ declare var ReadableStream: { from(iterable: string): ReadableStream; readonly prototype: ReadableStream; }; -/* The writable side of a TransformStream and its writer. The - * WritableStream constructor has no native lowering; writable streams come - * from `new TransformStream()`. */ +/* The writable side of a TransformStream and its writer. A program that + * constructs a WritableStream, or a TransformStream with a transformer, + * builds its streams with the web stream classes (scr_webstreams.c). */ interface WritableStreamDefaultWriter { readonly closed: Promise; readonly desiredSize: number | null; @@ -1133,14 +1133,25 @@ interface WritableStream { close(): Promise; getWriter(): WritableStreamDefaultWriter; } -/* The identity transform: chunks written to `writable` are read from - * `readable`, and a write completes when its chunk is read. */ +/* Without a transformer, the identity transform: chunks written to + * `writable` are read from `readable`, and a write completes when its chunk + * is read. */ interface TransformStream { readonly readable: ReadableStream; readonly writable: WritableStream; } +interface Transformer { + start?(controller: TransformStreamDefaultController): unknown; + transform?(chunk: I, controller: TransformStreamDefaultController): unknown; + flush?(controller: TransformStreamDefaultController): unknown; + cancel?(reason?: unknown): unknown; +} declare var TransformStream: { - new (): TransformStream; + new ( + transformer?: Transformer, + writableStrategy?: QueuingStrategy, + readableStrategy?: QueuingStrategy, + ): TransformStream; readonly prototype: TransformStream; }; /* The other web stream interfaces. JavaScript sources construct and read @@ -1158,8 +1169,14 @@ interface WritableStreamDefaultController { readonly signal: AbortSignal; error(reason?: unknown): void; } +interface UnderlyingSink { + start?(controller: WritableStreamDefaultController): unknown; + write?(chunk: W, controller: WritableStreamDefaultController): unknown; + close?(): unknown; + abort?(reason?: unknown): unknown; +} declare var WritableStream: { - new (sink?: object, strategy?: QueuingStrategy): WritableStream; + new (sink?: UnderlyingSink, strategy?: QueuingStrategy): WritableStream; readonly prototype: WritableStream; }; declare var WritableStreamDefaultWriter: { diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts index 07f5f09c8..e723d2df6 100644 --- a/packages/compiler/src/compat/fetch-profile.ts +++ b/packages/compiler/src/compat/fetch-profile.ts @@ -155,6 +155,12 @@ const typedInterfaceUnsupported = "typed source has no compiler bridge for this const metadataExclusion = "WebIDL brand metadata is observable reflection, not an executable compatibility operation"; +/** ReadableStream methods a typed program reaches through the web stream + * classes (scr_webstreams.c): on a stream it constructs with them, and on + * the native stream through its web view (scr_fetch.c, sf_stream_web_view). + * A program that calls one links the classes. */ +export const READABLE_STREAM_WEB_VIEW_CALLS = ["pipeThrough", "pipeTo", "tee"] as const; + function buildFetchCompatProfile() { return { schemaVersion: 1, @@ -233,7 +239,7 @@ function buildFetchCompatProfile() { ], responseCalls: ["json", "text", "bytes", "clone"], readableStreamReads: ["locked"], - readableStreamCalls: ["cancel", "getReader"], + readableStreamCalls: ["cancel", "getReader", ...READABLE_STREAM_WEB_VIEW_CALLS], }, operations: [ { @@ -447,6 +453,13 @@ function buildFetchCompatProfile() { facets: ["webidl-conversion", "identity", "state-machine", "error-shape"], evidence: [generated("stream-traces"), fixture("static-stream")], }, + ...READABLE_STREAM_WEB_VIEW_CALLS.map((member): FetchCompatOperation => ({ + id: `stdlib.readable-stream.${member}`, + name: `ReadableStream.${member}`, + kind: "method", + facets: ["identity", "promise-settlement", "state-machine", "error-shape"], + evidence: [fixture("static-web-streams")], + })), { id: "stdlib.readable-stream-default-reader.closed", name: "ReadableStreamDefaultReader.closed", @@ -757,25 +770,15 @@ function buildFetchCompatProfile() { "getReader", "prototype", ), - ...["pipeThrough", "values"].map((member) => - unsupportedEntry( - `stdlib.readable-stream.${member}`, - "ReadableStream", - member, - "prototype", - member === "values" - ? "direct for-await consumption compiles statically; storing or driving the first-class iterator handle is unsupported" - : "the wider Web Streams graph is outside the native readable-stream slice", - ), + ...READABLE_STREAM_WEB_VIEW_CALLS.map((member) => + staticEntry(`stdlib.readable-stream.${member}`, "ReadableStream", member, "prototype"), ), - ...["pipeTo", "tee"].map((member) => - unsupportedEntry( - `stdlib.readable-stream.${member}`, - "ReadableStream", - member, - "prototype", - "this operation is outside the native readable-stream implementation", - ), + unsupportedEntry( + "stdlib.readable-stream.values", + "ReadableStream", + "values", + "prototype", + "direct for-await consumption compiles statically; storing or driving the first-class iterator handle is unsupported", ), unsupportedEntry( "stdlib.readable-stream.symbol.asyncIterator", @@ -954,7 +957,8 @@ export interface StaticFetchMembers { readableStreamReads: ReadonlySet; readableStreamCalls: ReadonlySet; /** ReadableStream prototype members the native stream does not - * implement, symbol-keyed ones as `[Symbol.name]`. */ + * implement itself, symbol-keyed ones as `[Symbol.name]`: the unsupported + * ones, and the calls it answers through its web view. */ readableStreamUnsupported: ReadonlySet; } @@ -979,8 +983,8 @@ export function staticFetchMembers(): StaticFetchMembers { ), readableStreamReads: new Set(profile.members.readableStreamReads), readableStreamCalls: new Set(profile.members.readableStreamCalls), - readableStreamUnsupported: new Set( - profile.inventory.entries + readableStreamUnsupported: new Set([ + ...profile.inventory.entries .filter( (entry) => entry.owner === "ReadableStream" && @@ -988,7 +992,8 @@ export function staticFetchMembers(): StaticFetchMembers { entry.status === "unsupported", ) .map((entry) => entry.member), - ), + ...READABLE_STREAM_WEB_VIEW_CALLS, + ]), }; return staticFetchMemberSets; } diff --git a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts index 765d3beae..67caec84a 100644 --- a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts +++ b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts @@ -1,4 +1,7 @@ -import { staticFetchMembers } from "../../../compat/fetch-profile.js"; +import { + READABLE_STREAM_WEB_VIEW_CALLS, + staticFetchMembers, +} from "../../../compat/fetch-profile.js"; import { numLit } from "../../../ir/build.js"; import { everyStmt } from "../../../ir/traverse.js"; import { @@ -16,6 +19,7 @@ import { constituentTypes } from "../../ts7/checker.js"; import { ENTRY_NAME, type Lowerer } from "../lowerer.js"; import { stdlibGlobalNameOf } from "../surfaces.js"; import { streamWebImportOf } from "./module-bindings.js"; +import { lowerDynObjectLiteral } from "../expressions/object-literals.js"; /* The web stream classes in JavaScript sources: the globals and the * node:stream/web exports, which are the same constructors. Each reference @@ -92,6 +96,63 @@ export function programNamesWebStreamClass(lowerer: Lowerer): boolean { return found; } +/** The classes a TypeScript source constructs through the web stream + * classes once it uses an operation the native streams lack. */ +const TYPED_CLASSES: ReadonlySet = new Set([ + "ReadableStream", + "WritableStream", + "TransformStream", +]); +const WEB_VIEW_CALLS: ReadonlySet = new Set(READABLE_STREAM_WEB_VIEW_CALLS); + +/** Whether a TypeScript source of the program uses a web stream operation + * the native streams lack: a ReadableStream tee(), pipeTo() or + * pipeThrough() call, a WritableStream constructor, or a TransformStream + * constructor with arguments (a transformer or queuing strategies). Such a + * program builds its ReadableStream, WritableStream and TransformStream + * values with the web stream classes, as JavaScript does; a program that + * uses only getReader(), cancel() and the identity transform keeps the + * native streams. */ +export function programUsesTypedWebStreams(lowerer: Lowerer): boolean { + if (lowerer.typedWebStreams !== undefined) return lowerer.typedWebStreams; + let found = false; + for (const file of lowerer.program.getSourceFiles()) { + if (file.isDeclarationFile || isJsSourceFile(file)) continue; + ts.walkPreorder(file, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + WEB_VIEW_CALLS.has(node.name.text) && + isReadableStreamMember(lowerer, node) + ) + found = true; + else if (ts.isNewExpression(node)) { + const name = stdlibGlobalNameOf(lowerer, unparenthesized(node.expression)); + if ( + name === "WritableStream" || + (name === "TransformStream" && (node.arguments?.nodes.length ?? 0) > 0) + ) + found = true; + } + return found ? "stop" : undefined; + }); + if (found) break; + } + lowerer.typedWebStreams = found; + return found; +} + +/** The class a TypeScript `new` expression constructs through the web + * stream classes (programUsesTypedWebStreams), as its index in + * WEB_STREAM_CLASSES; null otherwise. */ +function typedWebStreamClassKindOf(lowerer: Lowerer, node: ts.Expression): number | null { + const expr = unparenthesized(node); + if (!ts.isIdentifier(expr) || isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + const name = streamWebImportOf(lowerer, expr) ?? stdlibGlobalNameOf(lowerer, expr); + if (name === null || !TYPED_CLASSES.has(name) || !programUsesTypedWebStreams(lowerer)) + return null; + return CLASS_NAMES.indexOf(name); +} + export function webStreamClassValue(kind: number, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "webstream.class", args: [numLit(kind, loc)], type: DYN, loc }; } @@ -102,13 +163,24 @@ export function lowerWebStreamClassRead(lowerer: Lowerer, node: ts.Expression): return kind === null ? null : webStreamClassValue(kind, locOf(node)); } -/** `new X(...args)` on one of the classes in JavaScript. */ +/** `new X(...args)` on one of the classes in JavaScript, and on + * ReadableStream, WritableStream and TransformStream in a TypeScript + * program that uses the web stream classes (programUsesTypedWebStreams). + * A TypeScript source's object literal argument (a source, sink, + * transformer or strategy) becomes a plain object with its methods. */ export function lowerWebStreamNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { - const kind = webStreamClassKindOf(lowerer, expr.expression); + const typed = webStreamClassKindOf(lowerer, expr.expression) === null; + const kind = typed + ? typedWebStreamClassKindOf(lowerer, expr.expression) + : webStreamClassKindOf(lowerer, expr.expression); if (kind === null) return null; const nodes = expr.arguments?.nodes ?? []; if (nodes.some(ts.isSpreadElement)) return null; const loc = locOf(expr); + const argument = (arg: ts.Expression): IrExpr => + typed && ts.isObjectLiteralExpression(arg) + ? lowerDynObjectLiteral(lowerer, arg) + : lowerer.lowerExprExpecting(arg, DYN); return { kind: "libCall", fn: "dyn.construct", @@ -116,7 +188,7 @@ export function lowerWebStreamNew(lowerer: Lowerer, expr: ts.NewExpression): IrE webStreamClassValue(kind, loc), { kind: "dynArrLit", - elems: nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + elems: nodes.map(argument), type: DYN, loc, }, @@ -285,6 +357,7 @@ function programLinksFetch(functions: IrFunction[]): boolean { * count in a program that links the fetch unit, since the value can be a * fetch body. */ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction[]): boolean { + if (programUsesTypedWebStreams(lowerer)) return true; const members = staticFetchMembers().readableStreamUnsupported; const iterates = members.has("[Symbol.asyncIterator]"); let untyped = false; @@ -310,7 +383,9 @@ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction } /** Links the web stream classes into a program whose JavaScript uses a - * ReadableStream member the fetch unit's own stream lacks: a fetch body's + * ReadableStream member the fetch unit's own stream lacks, or whose + * TypeScript uses the web stream classes (programUsesTypedWebStreams): a + * fetch body's or a native stream's * handle stands for a web ReadableStream once such a member is used * (scr_fetch.c, sf_stream_web_view). %main installs the classes first. A * program that names one of the classes links them already. */ diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 44f2b7370..f1db4bcf1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -1425,7 +1425,7 @@ export function fenceStaticReadableStreamMember( lowerer.noLowering( `ReadableStream.${member} in a static build`, access, - "the native static ReadableStream surface is locked plus cancel() and getReader(); values()/[Symbol.asyncIterator]() compile when consumed directly by for await, while stored iterator handles remain unsupported", + "ReadableStream's locked compiles as a read, and getReader(), cancel(), tee(), pipeTo() and pipeThrough() as direct calls; values()/[Symbol.asyncIterator]() compile when consumed directly by for await, while method values and stored iterator handles remain unsupported", sym, ); } @@ -1965,8 +1965,9 @@ export function lowerStaticReadableStreamNew( /** `new TransformStream()`: the identity transform, a native handle whose * readable side is a native ReadableStream and whose writable side is the - * runtime's WritableStream. A transformer or queuing strategies have no - * native lowering yet and keep the SC2020 refusal. */ + * runtime's WritableStream. A TransformStream with a transformer or + * queuing strategies is built with the web stream classes instead + * (web-streams-dyn.ts, lowerWebStreamNew). */ export function lowerStaticTransformStreamNew( lowerer: Lowerer, expr: ts.NewExpression, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6ae1ea9f5..c3a5ec42f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3783,6 +3783,9 @@ export class Lowerer { /** web-streams-dyn.ts's answer: whether a JavaScript source names a web * stream class; undefined until asked. */ namesWebStreamClass: boolean | undefined = undefined; + /** web-streams-dyn.ts's answer: whether a TypeScript source uses a web + * stream operation the native streams lack; undefined until asked. */ + typedWebStreams: boolean | undefined = undefined; /** The first lifted function of each name (liftedFnNamed), for the * liftedFns indexed so far. liftedFns only grows. */ private readonly liftedFnsByName = new Map(); @@ -4413,6 +4416,7 @@ export class Lowerer { "sloppyThisGlobalReads", "globalLookupNames", "namesWebStreamClass", + "typedWebStreams", "timeValueDateDecisions", "timeValueDateWrites", "dynamicOverloadAnswers", diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 36dae9b10..12aa672f4 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -220,8 +220,10 @@ export const FETCH_AMBIENT_TYPES = [ "ReadableStreamReadValueResult", "ReadableStreamReadDoneResult", "TransformStream", + "TransformStreamDefaultController", "WritableStream", "WritableStreamDefaultWriter", + "WritableStreamDefaultController", "Blob", "File", "BlobPropertyBag", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..480a18409 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -5039,17 +5039,15 @@ "id": "stdlib.readable-stream.pipeThrough", "kind": "stdlib", "name": "ReadableStream.pipeThrough", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; the wider Web Streams graph is outside the native readable-stream slice" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: identity, promise-settlement, state-machine, error-shape; differential evidence: fixture:static-web-streams" }, { "id": "stdlib.readable-stream.pipeTo", "kind": "stdlib", "name": "ReadableStream.pipeTo", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; this operation is outside the native readable-stream implementation" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: identity, promise-settlement, state-machine, error-shape; differential evidence: fixture:static-web-streams" }, { "id": "stdlib.readable-stream.symbol.asyncIterator", @@ -5063,9 +5061,8 @@ "id": "stdlib.readable-stream.tee", "kind": "stdlib", "name": "ReadableStream.tee", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; this operation is outside the native readable-stream implementation" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: identity, promise-settlement, state-machine, error-shape; differential evidence: fixture:static-web-streams" }, { "id": "stdlib.readable-stream.values", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-pipes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-pipes.ts.json new file mode 100644 index 000000000..8455ba03b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-pipes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/web-streams-typed-pipes.ts" + ], + "diags": [] +} diff --git a/tests/corpus/web-streams-typed-pipes.ts b/tests/corpus/web-streams-typed-pipes.ts new file mode 100644 index 000000000..ab5564baa --- /dev/null +++ b/tests/corpus/web-streams-typed-pipes.ts @@ -0,0 +1,158 @@ +// ReadableStream tee(), pipeTo() and pipeThrough() in TypeScript, with +// WritableStream sinks and TransformStream transformers: chunk order, close +// and flush, cancel through a tee branch, the pipe options, Node's +// TypeErrors for locked streams, error propagation through a pipe, and a +// Response body piped through a transform. + +const describe = (e: unknown): string => `${(e as Error).name}: ${(e as Error).message}`; + +function source(chunks: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); +} + +async function drain(stream: ReadableStream): Promise { + const out: T[] = []; + const reader = stream.getReader(); + for (;;) { + const result = await reader.read(); + if (result.done) break; + out.push(result.value); + } + return out; +} + +async function tees(): Promise { + const [left, right] = source(["a", "b", "c"]).tee(); + console.log("tee", (await drain(left)).join(""), (await drain(right)).join("")); + + const log: string[] = []; + const watched = new ReadableStream({ + start(controller) { + controller.enqueue(1); + controller.enqueue(2); + }, + cancel(reason) { + log.push(`source cancel ${String(reason)}`); + }, + }); + const [a, b] = watched.tee(); + // The source is cancelled once both branches are, with both reasons. + const leftDone = a.cancel("left done").then(() => log.push("left cancelled")); + log.push("left requested"); + await Promise.all([leftDone, b.cancel("right done").then(() => log.push("right cancelled"))]); + console.log("tee cancel", log.join(" | "), watched.locked); + + const locked = source(["x"]); + locked.getReader(); + try { + locked.tee(); + } catch (e) { + console.log("tee locked", describe(e)); + } +} + +async function pipes(): Promise { + const written: string[] = []; + const sink = new WritableStream({ + write(chunk) { + written.push(chunk); + }, + close() { + written.push(""); + }, + }); + await source(["one", "two"]).pipeTo(sink); + console.log("pipeTo", written.join(","), sink.locked); + + const kept: string[] = []; + const open = new WritableStream({ + write(chunk) { + kept.push(chunk); + }, + close() { + kept.push(""); + }, + }); + await source(["p"]).pipeTo(open, { preventClose: true }); + const writer = open.getWriter(); + await writer.write("after"); + await writer.close(); + console.log("preventClose", kept.join(",")); + + const failing = new ReadableStream({ + start(controller) { + controller.enqueue("ok"); + controller.error(new RangeError("source failed")); + }, + }); + const aborted: string[] = []; + try { + await failing.pipeTo( + new WritableStream({ + write(chunk) { + aborted.push(chunk); + }, + abort(reason) { + aborted.push(`abort ${describe(reason)}`); + }, + }), + ); + } catch (e) { + console.log("pipeTo error", describe(e), aborted.join(" | ")); + } + + const busy = source(["q"]); + busy.getReader(); + try { + await busy.pipeTo(new WritableStream()); + } catch (e) { + console.log("pipeTo locked", describe(e)); + } +} + +async function transforms(): Promise { + const upper = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk.toUpperCase()); + }, + flush(controller) { + controller.enqueue("!"); + }, + }); + console.log("pipeThrough", (await drain(source(["ab", "cd"]).pipeThrough(upper))).join("")); + + const lengths = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk.length); + }, + }); + console.log("lengths", (await drain(source(["a", "bbb", "cc"]).pipeThrough(lengths))).join(" ")); + + const identity = new TransformStream(); + console.log("identity", (await drain(source(["i", "j"]).pipeThrough(identity))).join("")); + + const body = new Response("fetch body").body!; + const decoded = body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(new TextDecoder().decode(chunk)); + }, + }), + ); + console.log("response body", (await drain(decoded)).join("")); + + const [first, second] = new Response("split").body!.tee(); + const decoder = new TextDecoder(); + const text = async (s: ReadableStream): Promise => + (await drain(s)).map((chunk) => decoder.decode(chunk)).join(""); + console.log("response tee", await text(first), await text(second)); +} + +await tees(); +await pipes(); +await transforms(); diff --git a/tests/fixtures/fetch/static-web-streams/main.mts b/tests/fixtures/fetch/static-web-streams/main.mts new file mode 100644 index 000000000..e8e171d66 --- /dev/null +++ b/tests/fixtures/fetch/static-web-streams/main.mts @@ -0,0 +1,96 @@ +// ReadableStream tee(), pipeTo() and pipeThrough() in a static build: a +// fetched body (whole and chunked) tee'd, piped into a WritableStream and +// through a TransformStream, constructed streams through a transformer, +// and Node's TypeErrors for locked streams and errors through a pipe. +const base = process.argv[2]!; +const describe = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)); +const decoder = new TextDecoder(); + +async function collect(stream: ReadableStream): Promise { + const reader = stream.getReader(); + let text = ""; + for (;;) { + const result = await reader.read(); + if (result.done) break; + text += decoder.decode(result.value, { stream: true }); + } + return text + decoder.decode(); +} + +const fetched = await fetch(`${base}/text`); +const [left, right] = fetched.body!.tee(); +console.log("tee", await collect(left), await collect(right), fetched.bodyUsed); + +const chunked = await fetch(`${base}/chunked`); +const parts: string[] = []; +await chunked.body!.pipeTo( + new WritableStream({ + write(chunk) { + parts.push(decoder.decode(chunk, { stream: true })); + }, + close() { + parts.push(""); + }, + }), +); +console.log("pipeTo", parts.join("").length > 0, parts[parts.length - 1], chunked.bodyUsed); + +const upper = await fetch(`${base}/text`); +const shouted = upper.body!.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(new TextEncoder().encode(decoder.decode(chunk).toUpperCase())); + }, + }), +); +console.log("pipeThrough", await collect(shouted)); + +const words = new ReadableStream({ + start(controller) { + controller.enqueue("alpha"); + controller.enqueue("beta"); + controller.close(); + }, +}); +const counted = words.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk.length); + }, + flush(controller) { + controller.enqueue(-1); + }, + }), +); +const counts: number[] = []; +const reader = counted.getReader(); +for (;;) { + const result = await reader.read(); + if (result.done) break; + counts.push(result.value); +} +console.log("constructed", counts.join(" ")); + +const locked = await fetch(`${base}/text`); +locked.body!.getReader(); +try { + locked.body!.tee(); +} catch (e) { + console.log("tee locked", describe(e)); +} +try { + await locked.body!.pipeTo(new WritableStream()); +} catch (e) { + console.log("pipeTo locked", describe(e)); +} + +const failing = new ReadableStream({ + start(controller) { + controller.error(new TypeError("source failed")); + }, +}); +try { + await failing.pipeTo(new WritableStream()); +} catch (e) { + console.log("pipeTo error", describe(e)); +} diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index ada6231d4..073a031e5 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -142,41 +142,23 @@ fenced.ts:80:3 - error SC2020: 'RequestInit option 'integrity' in a static build hint: the compiler does not preserve this RequestInit member's conversion or transport behavior -fenced.ts:84:3 - error SC2020: 'ReadableStream.tee' is typed by @types/node but has no scriptc lowering yet - - 83 | const body = (await fetch(url)).body!; - 84 | body.tee(); - | ^~~~~~~~ - 85 | const tee = body.tee; - - hint: this operation is outside the native readable-stream implementation - -fenced.ts:85:15 - error SC2020: 'ReadableStream.tee' is typed by @types/node but has no scriptc lowering yet +fenced.ts:85:15 - error SC2020: 'ReadableStream.tee in a static build' is typed by @types/node but has no scriptc lowering yet 84 | body.tee(); 85 | const tee = body.tee; | ^~~~~~~~ 86 | body["tee"](); - hint: this operation is outside the native readable-stream implementation + hint: ReadableStream's locked compiles as a read, and getReader(), cancel(), tee(), pipeTo() and pipeThrough() as direct calls; values()/[Symbol.asyncIterator]() compile when consumed directly by for await, while method values and stored iterator handles remain unsupported -fenced.ts:86:3 - error SC2020: 'ReadableStream.tee' is typed by @types/node but has no scriptc lowering yet - - 85 | const tee = body.tee; - 86 | body["tee"](); - | ^~~~~~~~~~~ - 87 | const bracketTee = body["tee"]; - - hint: this operation is outside the native readable-stream implementation - -fenced.ts:87:22 - error SC2020: 'ReadableStream.tee' is typed by @types/node but has no scriptc lowering yet +fenced.ts:87:22 - error SC2020: 'ReadableStream.tee in a static build' is typed by @types/node but has no scriptc lowering yet 86 | body["tee"](); 87 | const bracketTee = body["tee"]; | ^~~~~~~~~~~ 88 | } - hint: this operation is outside the native readable-stream implementation + hint: ReadableStream's locked compiles as a read, and getReader(), cancel(), tee(), pipeTo() and pipeThrough() as direct calls; values()/[Symbol.asyncIterator]() compile when consumed directly by for await, while method values and stored iterator handles remain unsupported fenced.ts:93:23 - error SC2020: 'util.callbackify into a callback whose error parameter excludes null' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/corpus-costs/web-streams-typed-pipes.ts.json b/tests/harness/corpus-costs/web-streams-typed-pipes.ts.json new file mode 100644 index 000000000..5cce6d4a6 --- /dev/null +++ b/tests/harness/corpus-costs/web-streams-typed-pipes.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 6000 } diff --git a/tests/harness/fetch.test.ts b/tests/harness/fetch.test.ts index 1df7965f6..acd93af81 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -165,6 +165,7 @@ describe(`static fetch differential${sanitize ? " (sanitized)" : ""}`, () => { "static-dispatch-throw", "static-response-json", "static-clone", + "static-web-streams", ] as const; test.for( staticCases.flatMap((name) => (["llvm"] as const).map((backend) => [name, backend] as const)), diff --git a/tests/harness/surface-manifest.test.ts b/tests/harness/surface-manifest.test.ts index 710cc529b..c4650151f 100644 --- a/tests/harness/surface-manifest.test.ts +++ b/tests/harness/surface-manifest.test.ts @@ -450,7 +450,7 @@ test("unimplemented Response and ReadableStream calls stay fenced", () => { " void value.blob(); void value.formData();\n" + "}\n" + "function stream(value: ReadableStream): void {\n" + - " void value.tee(); void value.pipeTo(null as never);\n" + + " void value.values(); void value[Symbol.asyncIterator]();\n" + "}\n" + "void response; void stream;\n", }); From 6d07a49cbfc4ef88852f173681c43a4994a3a8ed Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:24:06 -0500 Subject: [PATCH 031/193] Return freed memory sooner: chunked shared writes, a bounded string spare, idle trims Three runtime changes that lower peak memory without changing behavior: - In programs with workers, process.stdout.write and process.stderr.write of a view into a SharedArrayBuffer read it through a 64 KiB buffer instead of copying the whole view first, so writing a 32 MB shared view no longer holds a second 32 MB. Programs without workers keep the direct write. - The one-slot string spare keeps freed blocks of 512 bytes to 1 MiB only. A larger block stayed resident until a string of about its size came along, which a program that replaces its input with an edited copy never does. - The main thread trims its arena when its event loop waits with nothing ready, once it has carved 16 MB more since its last trim, as a parking thread does: pages that hold only free blocks go back to the system while the program waits on its workers. --- .../corpus/shared-bytes-stdio-write.ts.json | 6 +++ packages/runtime/src/scr_alloc.c | 22 +++++++++- packages/runtime/src/scr_alloc.test.c | 44 +++++++++++++++++++ packages/runtime/src/scr_bytes_io.c | 29 ++++++++++-- packages/runtime/src/scr_string.c | 8 +++- tests/corpus/shared-bytes-stdio-write.ts | 27 ++++++++++++ .../shared-bytes-stdio-write.ts.json | 1 + 7 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shared-bytes-stdio-write.ts.json create mode 100644 tests/corpus/shared-bytes-stdio-write.ts create mode 100644 tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shared-bytes-stdio-write.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shared-bytes-stdio-write.ts.json new file mode 100644 index 000000000..33c91a4c1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shared-bytes-stdio-write.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shared-bytes-stdio-write.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 13b83a396..11e675542 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -1599,10 +1599,30 @@ static void scr_sa_group_starting(void) { * waits. Only that thread writes it. */ static _Atomic(unsigned long) scr_sa_loop_turns; +/* The carved bytes of the main thread's arena at its last idle trim. */ +static size_t scr_sa_idle_carved; + +/* The main thread returns its free pages when its loop waits with nothing + * ready, as a parking thread does (scr_sa_trim), once it has carved at least + * SCR_SA_TRIM_MIN more since its last trim: memory it freed (a large teardown + * before it starts workers, say) then stops counting while the workers + * allocate their own. Measuring the carved bytes costs one pass over the + * classes; the trim itself only runs when the frontier has grown. */ +static void scr_sa_idle_trim(void) { + if (scr_sa_arena < 0) return; + size_t carved = scr_sa_carved(); + if (carved - scr_sa_idle_carved < SCR_SA_TRIM_MIN) return; + scr_sa_trim(); + scr_sa_idle_carved = scr_sa_carved(); +} + void scr_sa_loop_waiting(bool waiting) { if (scr_sa_worker) return; unsigned long turns = atomic_load_explicit(&scr_sa_loop_turns, memory_order_relaxed); - if ((bool)(turns & 1) != waiting) atomic_store_explicit(&scr_sa_loop_turns, turns + 1, memory_order_relaxed); + if ((bool)(turns & 1) != waiting) { + atomic_store_explicit(&scr_sa_loop_turns, turns + 1, memory_order_relaxed); + if (waiting) scr_sa_idle_trim(); + } } /* Waits out the delay first, and stops if a worker starts meanwhile: the diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index bf9fc7e64..ce57aa48c 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -923,6 +923,49 @@ static void check_discard_waits_for_loop(void) { free(discard_blocks); } +/* The thread that is not a worker returns its free pages when its loop + * waits with nothing ready, once it has carved SCR_SA_TRIM_MIN more since + * its last trim; blocks allocated afterwards come from the returned range, + * zeroed. The test's thread plays the loop's. IDLE_SIZE is a class the + * other checks leave unused. */ +enum { IDLE_BLOCKS = 1 << 20, IDLE_KEEP = 4096, IDLE_SIZE = 88 }; +static void check_idle_trim(void) { + unsigned char **blocks = malloc(IDLE_BLOCKS * sizeof *blocks); + unsigned char *kept[IDLE_BLOCKS / IDLE_KEEP]; + assert(blocks); + uintptr_t lo = UINTPTR_MAX, hi = 0; + for (size_t i = 0; i < IDLE_BLOCKS; i++) { + blocks[i] = scr_mem_alloc(IDLE_SIZE); + assert(blocks[i] && owned(blocks[i])); + fill(blocks[i], IDLE_SIZE, (unsigned char)i); + if ((uintptr_t)blocks[i] < lo) lo = (uintptr_t)blocks[i]; + if ((uintptr_t)blocks[i] + IDLE_SIZE > hi) hi = (uintptr_t)blocks[i] + IDLE_SIZE; + } + for (size_t i = 0; i < IDLE_BLOCKS; i++) { + if (i % IDLE_KEEP == 0) kept[i / IDLE_KEEP] = blocks[i]; + else scr_mem_free(blocks[i]); + } + size_t total = (hi - lo) / (size_t)sysconf(_SC_PAGESIZE); + assert(resident_pages(lo, hi) > total / 2); + scr_sa_loop_waiting(true); + scr_sa_loop_waiting(false); + /* One page per kept block stays, give or take the pages at run ends. */ + assert(resident_pages(lo, hi) < total / 8); + for (size_t k = 0; k < IDLE_BLOCKS / IDLE_KEEP; k++) verify(kept[k], IDLE_SIZE, (unsigned char)(k * IDLE_KEEP)); + size_t outside = 0; + for (size_t i = 0; i < IDLE_BLOCKS / 2; i++) { + unsigned char *q = scr_mem_calloc(IDLE_SIZE); + assert(q && owned(q)); + for (size_t k = 0; k < IDLE_SIZE; k++) assert(q[k] == 0); + if ((uintptr_t)q < lo || (uintptr_t)q >= hi) outside++; + blocks[i] = q; + } + assert(outside < 256); + for (size_t i = 0; i < IDLE_BLOCKS / 2; i++) scr_mem_free(blocks[i]); + for (size_t k = 0; k < IDLE_BLOCKS / IDLE_KEEP; k++) scr_mem_free(kept[k]); + free(blocks); +} + static void check_discard(void) { discard_blocks = malloc(DISCARD_BLOCKS * sizeof *discard_blocks); assert(discard_blocks); @@ -1172,6 +1215,7 @@ int main(void) { check_discard(); check_discard_delay_grows(); check_discard_waits_for_loop(); + check_idle_trim(); #endif printf("allocator checks passed: size-class workers\n"); #else diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 0c4bee4ee..2459d2e05 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1226,16 +1226,37 @@ ScrBytes *scr_crypto_random_bytes(double n) { /* ── process.stdout/stderr.write(buf) ──────────────────────────────────── */ +/* Where another thread can write shared bytes (where SCR_BYTES_SNAPSHOT + * copies them), they are written through a bounded local buffer, read with + * scr_shared_load, so no shared raw memory reaches the system call and a + * large shared view is not copied whole first. */ +static bool scr_stdio_write_bytes(int fd, const ScrBytes *b) { + size_t n = b->len * scr_bytes_elem_size(b->elem); +#if defined(SCR_WORKERS) || (defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)) + if (!b->shared) return scr_stdio_write(fd, b->data, n); + enum { CHUNK = 64 * 1024 }; + unsigned char *chunk = scr_mem_alloc(n < CHUNK ? (n ? n : 1) : CHUNK); + bool ok = true; + for (size_t off = 0; ok && off < n; off += CHUNK) { + size_t k = n - off < CHUNK ? n - off : CHUNK; + scr_shared_load(chunk, b->data + off, k); + ok = scr_stdio_write(fd, chunk, k); + } + scr_mem_free(chunk); + return ok; +#else + return scr_stdio_write(fd, b->data, n); +#endif +} + bool scr_process_stdout_write_bytes(const ScrBytes *b, const ScrStr *encoding) { - SCR_BYTES_SNAPSHOT(b); (void)encoding; - return scr_stdio_write(1, b->data, b->len * scr_bytes_elem_size(b->elem)); + return scr_stdio_write_bytes(1, b); } bool scr_process_stderr_write_bytes(const ScrBytes *b, const ScrStr *encoding) { - SCR_BYTES_SNAPSHOT(b); (void)encoding; - return scr_stdio_write(2, b->data, b->len * scr_bytes_elem_size(b->elem)); + return scr_stdio_write_bytes(2, b); } /* Buffer.from over checked-native input. Strings use the existing codec; diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index d712ebb84..14203cf96 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -432,9 +432,13 @@ static ScrStr *scr_str_new_ascii(const char *bytes, size_t len) { #ifndef SCR_RC_AUDIT static SCR_TL ScrStr *scr_str_spare; #endif +/* The largest block the spare keeps. A larger one stays resident until a + * string of about its size comes along, which may be never: a program that + * replaces a 50 MB input with its edited copy would otherwise hold both. */ +#define SCR_STR_SPARE_MAX ((size_t)1 << 20) /* A spare-block reuse must not waste grossly (cap <= 4x the need) and only - * sizable blocks are worth stashing (>= 512). */ + * sizable blocks are worth stashing (512 bytes to SCR_STR_SPARE_MAX). */ static ScrStr *scr_str_take_spare(size_t len) { #ifndef SCR_RC_AUDIT ScrStr *s = scr_str_spare; @@ -483,7 +487,7 @@ void scr_str_release(ScrStr *s) { scr_live_strings--; #endif #ifndef SCR_RC_AUDIT - if (s->cap >= 512) { + if (s->cap >= 512 && s->cap <= SCR_STR_SPARE_MAX) { ScrStr *old = scr_str_spare; scr_str_spare = s; if (!old) return; diff --git a/tests/corpus/shared-bytes-stdio-write.ts b/tests/corpus/shared-bytes-stdio-write.ts new file mode 100644 index 000000000..0a138fb77 --- /dev/null +++ b/tests/corpus/shared-bytes-stdio-write.ts @@ -0,0 +1,27 @@ +// process.stdout.write and process.stderr.write of views into a SharedArrayBuffer that a worker +// filled: a view larger than one write chunk (64 KiB), at a byte offset, one shorter than a chunk, +// and an empty one. +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +type Fill = { buffer: SharedArrayBuffer }; + +if (isMainThread) { + const sab = new SharedArrayBuffer(200_000); + const worker = new Worker(new URL(import.meta.url), { workerData: { buffer: sab } }); + worker.on("message", () => { + process.stdout.write(new Uint8Array(sab, 1000, 150_000)); + process.stdout.write("\n"); + process.stdout.write(new Uint8Array(sab, 0, 0)); + + process.stdout.write(new Uint8Array(sab, 160_000, 20_000)); + process.stdout.write("\n"); + + process.stderr.write(new Uint8Array(sab, 63, 70_000)); + process.stderr.write("\n"); + console.log("done"); + }); +} else { + const bytes = new Uint8Array((workerData as Fill).buffer); + for (let i = 0; i < bytes.length; i++) bytes[i] = i % 64 === 63 ? 10 : 65 + (i % 26); + parentPort!.postMessage("filled"); +} diff --git a/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json b/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json new file mode 100644 index 000000000..46d9e66e9 --- /dev/null +++ b/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json @@ -0,0 +1 @@ +{ "plain": 8136, "san": 68380 } From 8c79a0f6b8fcdee8a7b361e4c32edad476764868 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:42:43 -0500 Subject: [PATCH 032/193] Pass only the line to a patched stdio write without ignoreErrors A Console instance over process stdio passed a patched write the line and an error handler, and discarded what the write threw, whatever its ignoreErrors setting. Node's kWriteToConsole does that only with ignoreErrors (the default, and the global console). With ignoreErrors false, from the options or the third constructor argument, it calls stream.write(line) alone and lets a throw propagate out of the console method. The console write path now takes the instance's ignoreErrors, so such an instance passes only the line and keeps the write's exception pending. Instances bound to other streams already followed the setting. --- ...stance-ignore-errors-stdout-patch.cjs.json | 6 +++ packages/runtime/src/scr_console.c | 26 ++++++----- packages/runtime/src/scr_console.h | 4 +- packages/runtime/src/scr_console_native.c | 2 +- ...le-instance-ignore-errors-stdout-patch.cjs | 43 +++++++++++++++++++ ...stance-ignore-errors-stdout-patch.cjs.json | 1 + 6 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs.json create mode 100644 tests/corpus/console-instance-ignore-errors-stdout-patch.cjs create mode 100644 tests/harness/corpus-costs/console-instance-ignore-errors-stdout-patch.cjs.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs.json new file mode 100644 index 000000000..8fa6ef329 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 040cb2234..d28f058fa 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -387,9 +387,12 @@ SCR_TL char *(*scr_console_indenter)(const char *line, size_t *used) = NULL; /* Console methods submit one formatted chunk to their backing stream before * returning, as Node's console does: a caller observing a live child sees - * this line before the next JS turn. Errors are ignored (the global Node - * console uses ignoreErrors=true for stream writes). */ -static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool global) { + * this line before the next JS turn. With ignoreErrors (the global console, + * and Console instances by default) a patched write receives the line and + * an error handler, and what it throws is discarded; without it the write + * receives only the line and its throw propagates, as in Node's + * kWriteToConsole. */ +static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool global, bool ignore_errors) { #if SCR_STDIO_DIRECT /* A large single string goes out without a copy: string + newline in one * vectored write. */ @@ -420,8 +423,9 @@ static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool glob } if (scr_stdio_write_hook) { /* A patched write receives the JavaScript string, lone surrogates kept. */ - scr_stdio_write_hook(fd, line, used, (wide ? SCR_STDIO_HOOK_WIDE : 0) | SCR_STDIO_HOOK_CONSOLE); - if (scr_exc_pending()) scr_exc_clear(); + scr_stdio_write_hook(fd, line, used, + (wide ? SCR_STDIO_HOOK_WIDE : 0) | (ignore_errors ? SCR_STDIO_HOOK_CONSOLE : 0)); + if (ignore_errors && scr_exc_pending()) scr_exc_clear(); } else { if (wide) scr_wtf8_to_utf8(line, used); (void)scr_stdio_write_raw(fd, line, used); @@ -430,7 +434,7 @@ static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool glob } void scr_console_log(size_t n, const ScrLogArg *args) { - scr_console_write(1, n, args, true); + scr_console_write(1, n, args, true, true); } /* console.error and console.warn. Runtime-internal stdout output that is @@ -438,21 +442,21 @@ void scr_console_log(size_t n, const ScrLogArg *args) { * merged redirection (2>&1); JavaScript-visible stdout writes have already * been submitted. */ void scr_console_error(size_t n, const ScrLogArg *args) { - scr_console_write(2, n, args, true); + scr_console_write(2, n, args, true, true); } void scr_console_log_parts(size_t n, const ScrLogArg *args) { - scr_console_write(1, n, args, true); + scr_console_write(1, n, args, true, true); } void scr_console_error_parts(size_t n, const ScrLogArg *args) { - scr_console_write(2, n, args, true); + scr_console_write(2, n, args, true, true); } /* A Console instance's line on process stdout (fd 1) or stderr (fd 2): * the global console's group indentation does not apply. */ -void scr_console_write_fd(int fd, size_t n, const ScrLogArg *args) { - scr_console_write(fd == 2 ? 2 : 1, n, args, false); +void scr_console_write_fd(int fd, size_t n, const ScrLogArg *args, bool ignore_errors) { + scr_console_write(fd == 2 ? 2 : 1, n, args, false, ignore_errors); } /* One JavaScript-visible raw write. Node's global console and process stream diff --git a/packages/runtime/src/scr_console.h b/packages/runtime/src/scr_console.h index 10491d053..5b21d7462 100644 --- a/packages/runtime/src/scr_console.h +++ b/packages/runtime/src/scr_console.h @@ -41,8 +41,8 @@ void scr_console_error(size_t n, const ScrLogArg *args); void scr_console_log_parts(size_t n, const ScrLogArg *args); void scr_console_error_parts(size_t n, const ScrLogArg *args); /* A Console instance's line on fd 1 or 2, without the global console's - * group indentation. */ -void scr_console_write_fd(int fd, size_t n, const ScrLogArg *args); + * group indentation, under the instance's ignoreErrors. */ +void scr_console_write_fd(int fd, size_t n, const ScrLogArg *args, bool ignore_errors); /* The global console's line rewrite while a group is open (scr_console.c; * installed by scr_console_group): the indented heap copy of a rendered * line, its length in *used. */ diff --git a/packages/runtime/src/scr_console_native.c b/packages/runtime/src/scr_console_native.c index 82dc476a1..7590f1ace 100644 --- a/packages/runtime/src/scr_console_native.c +++ b/packages/runtime/src/scr_console_native.c @@ -120,7 +120,7 @@ static void scr_console_inst_print(ScrConsoleInst *inst, int which, ScrStr *text } ScrLogArg arg = {.tag = SCR_ARG_STR}; arg.v.s = text; - scr_console_write_fd(inst->fd[which] == 2 ? 2 : 1, 1, &arg); + scr_console_write_fd(inst->fd[which] == 2 ? 2 : 1, 1, &arg, inst->ignore_errors); } /* 0 for the stdout members, 1 for the stderr members, -1 otherwise. */ diff --git a/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs b/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs new file mode 100644 index 000000000..907c4b07b --- /dev/null +++ b/tests/corpus/console-instance-ignore-errors-stdout-patch.cjs @@ -0,0 +1,43 @@ +// What a replaced process.stdout.write receives from a Console instance over +// process.stdout: with ignoreErrors (the default) the line and an error +// handler, and a throw from the write is discarded; with ignoreErrors false, +// as an option or the third constructor argument, only the line, and the +// throw propagates out of the console method. +const { Console } = require("node:console"); +const original = process.stdout.write; +const seen = []; +let fail = false; +process.stdout.write = function (chunk, encodingOrCallback, callback) { + seen.push(`${typeof chunk}:${JSON.stringify(String(chunk))}:${typeof encodingOrCallback}:${typeof callback}`); + if (fail) throw new Error("write failed"); + if (typeof encodingOrCallback === "function") encodingOrCallback(null); + return true; +}; +const strict = new Console({ stdout: process.stdout, ignoreErrors: false }); +const lenient = new Console({ stdout: process.stdout }); +const positional = new Console(process.stdout, process.stderr, false); +strict.log("strict", 1); +lenient.log("lenient"); +positional.info("positional"); +fail = true; +try { + strict.log("throws"); + seen.push("strict: no throw"); +} catch (e) { + seen.push(`strict threw ${e.message}`); +} +try { + lenient.log("swallowed"); + seen.push("lenient: no throw"); +} catch (e) { + seen.push(`lenient threw ${e.message}`); +} +try { + console.log("global"); + seen.push("global: no throw"); +} catch (e) { + seen.push(`global threw ${e.message}`); +} +fail = false; +process.stdout.write = original; +console.log(seen.join("\n")); diff --git a/tests/harness/corpus-costs/console-instance-ignore-errors-stdout-patch.cjs.json b/tests/harness/corpus-costs/console-instance-ignore-errors-stdout-patch.cjs.json new file mode 100644 index 000000000..5e13b3a7c --- /dev/null +++ b/tests/harness/corpus-costs/console-instance-ignore-errors-stdout-patch.cjs.json @@ -0,0 +1 @@ +{ "plain": 3440, "san": 4500 } From 16ea77108bb9fb5958dfdde56baff833b40410f4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:57:15 -0500 Subject: [PATCH 033/193] Reuse lowered units across builds behind SCRIPTC_INCR_LOWERING=1 --- .github/workflows/ci.yml | 2 + package.json | 1 + packages/compiler/src/executable/prepare.ts | 10 +- .../compiler/src/frontend/lowering/lowerer.ts | 201 +- .../frontend/lowering/unit-journal.test.ts | 87 +- .../src/frontend/lowering/unit-journal.ts | 434 +- .../src/frontend/lowering/unit-reuse.ts | 1030 ++++ packages/compiler/src/frontend/type-mapper.ts | 38 +- packages/compiler/src/index.ts | 39 + packages/compiler/src/ir/codec.generated.ts | 5099 +++++++++++++++++ packages/compiler/src/ir/codec.test.ts | 235 + packages/compiler/src/ir/codec.ts | 376 ++ packages/compiler/src/ir/fields.generated.ts | 34 +- packages/compiler/src/ir/ir.ts | 84 +- packages/compiler/src/ir/serialize.ts | 7 +- packages/compiler/src/native/cache.ts | 2 + packages/compiler/src/native/prepare.ts | 61 +- packages/compiler/test/unit-reuse.test.ts | 315 + scripts/generate-ir-codec.mjs | 440 ++ 19 files changed, 8361 insertions(+), 134 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/unit-reuse.ts create mode 100644 packages/compiler/src/ir/codec.generated.ts create mode 100644 packages/compiler/src/ir/codec.test.ts create mode 100644 packages/compiler/src/ir/codec.ts create mode 100644 packages/compiler/test/unit-reuse.test.ts create mode 100644 scripts/generate-ir-codec.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23936e46a..4fbd624fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,8 @@ jobs: run: pnpm generate:ts7-ast --check - name: Verify the generated IR field readers run: pnpm generate:ir-fields --check + - name: Verify the generated IR codec + run: pnpm generate:ir-codec --check native_macos: needs: [validation_scope, lanes] diff --git a/package.json b/package.json index d049a4ecb..5540010f2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:ts7": "node scripts/test-ts7.mjs", "test:checker-identity": "node scripts/checker-identity.mjs", "generate:ts7-ast": "node scripts/generate-ts7-ast-schema.mjs", + "generate:ir-codec": "node scripts/generate-ir-codec.mjs", "generate:ir-fields": "node scripts/generate-ir-fields.mjs", "test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts", "test:test262": "tsx tests/test262/run.ts", diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 2629a65d5..614e60155 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -20,6 +20,7 @@ import { } from "../backend/target-diagnostics.js"; import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js"; import type { FrontendFactory } from "../frontend/pipeline.js"; +import type { UnitStore } from "../frontend/lowering/unit-reuse.js"; import type { CompilationTiming } from "../timing.js"; import { readProjectConfig } from "../project-config.js"; import { nodeIslandPolicy } from "../node-islands/policy.js"; @@ -42,7 +43,9 @@ export interface PreparedExecutableModule { warnings: ScrDiagnostic[]; } -/** Release the parser/checker before native object generation begins. */ +/** Release the parser/checker before native object generation begins. + * `unitStore` keeps the lowered units of this build for the next one + * (unit-reuse.ts). */ export function prepareExecutableModule( entryPath: string, opts: CompileRequestOptions, @@ -52,6 +55,7 @@ export function prepareExecutableModule( timing: CompilationTiming = () => {}, lowerIslands: ReadonlyMap = new Map(), retryFences = true, + unitStore: UnitStore | null = null, ): PreparedExecutableModule | CompileFailure { // The island decision reads the detector's scan unless islands are off. const islandScan = @@ -115,6 +119,7 @@ export function prepareExecutableModule( timing, grown, retryFences, + unitStore, ); } return fail(fe.preflight); @@ -127,6 +132,7 @@ export function prepareExecutableModule( targetPlatform: buildPlatform, ...(ffi !== null ? { ffiImports: ffi.functions } : {}), ...(opts.tier !== undefined ? { tierMode: opts.tier } : {}), + ...(unitStore !== null ? { unitStore } : {}), }, (module) => validateModule(module).length === 0, ); @@ -165,6 +171,7 @@ export function prepareExecutableModule( timing, grown, retryFences, + unitStore, ); } // A failed program whose compiled packages need the runtime or a @@ -267,6 +274,7 @@ export function prepareExecutableModule( timing, islands, false, + unitStore, ); if (islanded.ok) return islanded; firstError ??= islanded.diagnostics[0] ?? null; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..e04e37953 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -30,7 +30,9 @@ import { irDataTable, journalDump, journalMode, + HeldTables, lowerWaveRecorded, + type WaveReuse, mapTable, setTable, waveStats, @@ -41,6 +43,7 @@ import { type UnitEvents, type WaveHost, } from "./unit-journal.js"; +import { UnitReuse, unitReuseVerify, verifyStoredUnit, type UnitStore } from "./unit-reuse.js"; import { moduleNamespaceDynValue, moduleNamespaceObject, @@ -961,6 +964,9 @@ export interface LowerOptions { nodeIslands?: NodeIslandSet | null; /** Parser/checker services owned by the load; required for npm graph analysis. */ frontendServices?: FrontendServices | undefined; + /** Where reachable emit stores its units' records and finds an earlier + * build's (unit-reuse.ts). */ + unitStore?: UnitStore | null; /** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */ projectCheckJs?: boolean; /** Coverage: additionally lower the unreached remainder (bodies nothing @@ -1360,7 +1366,7 @@ function lowerToIrWithIslands( }); for (const d of extraModuleCycleDiags) reachableEmit.pushDiag(d); for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d); - const emitted = reachableEmit.emitReachable(options.libRoots); + const emitted = reachableEmit.emitReachable(options.libRoots, options.unitStore ?? null); let { reachable } = emitted; let result = emitted.result; let resultLowerer = reachableEmit; @@ -9940,7 +9946,10 @@ export class Lowerer { * demand-driven) and lifted lambdas lower inline with their enclosing * body; both fire edges through the same hooks and are not units * themselves. */ - emitReachable(extraRoots?: readonly string[]): { reachable: Set; result: LowerResult } { + emitReachable( + extraRoots?: readonly string[], + unitStore: UnitStore | null = null, + ): { reachable: Set; result: LowerResult } { // Frontend passes below rewrite function bodies through the IR mapper // (caught-value and class dispatch); key-order notes follow the record // literals they rebuild until the witness pass has read them. @@ -9998,12 +10007,16 @@ export class Lowerer { // Every lowerable body, by emitted-function name. The names double as // retained-function keys and are deterministic by construction // (qualified declaration names). + // `source` is the declaration whose text a unit lowers and `owners` the + // declarations around it (unit-reuse.ts). const units = new Map< string, { order: number; roots: readonly ts.Node[]; lower: () => IrFunction | null; + source?: ts.Node | undefined; + owners?: readonly ts.Node[]; } >(); // Roots are collected with plain pushes: spread arguments into a rest @@ -10047,6 +10060,7 @@ export class Lowerer { order: unitOrder++, roots: functionRoots(decl), lower: () => this.lowerFunction(decl), + source: decl, }); } } @@ -10067,6 +10081,7 @@ export class Lowerer { roots: classCtorRoots(info), lower: () => info.localClass && !info.localClass.ready ? null : this.lowerClassCtor(info), + source: info.decl ?? undefined, }); // The descriptor helpers of a class that extends Array, reached // from its constructor. @@ -10075,11 +10090,13 @@ export class Lowerer { order: unitOrder++, roots: [], lower: () => arrayClassFieldsFn(this, info), + source: info.decl ?? undefined, }); units.set(`%${cName}.species`, { order: unitOrder++, roots: [], lower: () => arrayClassSpeciesFn(this, info), + source: info.decl ?? undefined, }); } for (const { mName, member } of this.classMethodMembers(info)) { @@ -10090,6 +10107,8 @@ export class Lowerer { info.localClass && !info.localClass.ready ? null : this.lowerClassMethodMember(info, member), + source: member, + owners: info.decl ? [info.decl] : [], }); } for (const prop of info.throwingSetters) { @@ -10097,6 +10116,7 @@ export class Lowerer { order: unitOrder++, roots: [], lower: () => this.throwingSetterFn(info, prop), + source: info.decl ?? undefined, }); } } @@ -10106,6 +10126,8 @@ export class Lowerer { order: unitOrder++, roots: functionRoots(entry.member), lower: () => lowerStaticMethod(this, info, name), + source: entry.member, + owners: info.decl ? [info.decl] : [], }); } } @@ -10420,17 +10442,94 @@ export class Lowerer { ), ); }; - const recorded = journalMode() === "serial" ? waveStats() : null; - const host = recorded === null ? null : this.waveHost(loweredUnits, lowerUnitBody); + // With a unit store, every unit is recorded as in the serial mode, and + // a unit applies the record an earlier build stored for it where that + // record still holds (unit-reuse.ts). + const reuse = + unitStore === null + ? null + : UnitReuse.open( + unitStore, + this.program, + parts.map((fp) => fp.sf), + { + facts: (declaration) => this.declarationFacts(declaration), + exportsOf: (specifier) => { + const symbol = this.checker.getSymbolAtLocation(specifier); + if (!symbol) return null; + return this.checker + .getExportsOfModule(symbol) + .map((exported) => ts.symbolName(exported)) + .sort() + .join("\n"); + }, + isLocal: (identifier, container) => { + const declarations = this.checker.getSymbolAtLocation(identifier)?.declarations; + if (declarations === undefined || declarations.length === 0) return false; + const sf = ts.getSourceFileOfNode(container); + return declarations.every( + (declaration) => + ts.getSourceFileOfNode(declaration) === sf && + declaration.pos >= container.pos && + declaration.end <= container.end, + ); + }, + }, + ); + const recorded = + journalMode() === "serial" || (reuse !== null && reuse.records) ? waveStats() : null; + const host = + recorded === null + ? null + : this.waveHost(loweredUnits, lowerUnitBody, (name) => { + const priority = metadataPriority.get(name)!; + return { owner: demandOwner(priority), priority }; + }); const dump = recorded === null ? null : journalDump(); + const fileOfUnit = (name: string): string | null => { + const source = units.get(name)?.source; + return source === undefined ? null : ts.getSourceFileOfNode(source).fileName; + }; + const waveReuse: WaveReuse | null = + reuse === null || !reuse.records + ? null + : { + verify: unitReuseVerify(), + stored: (name) => { + // A unit without a declared rank takes the next expression + // rank when it lowers, which applying a record would not + // advance. + const unit = units.get(name); + const file = fileOfUnit(name); + if (unit === undefined || file === null || !metadataPriority.has(name)) return null; + return reuse.candidate(name, file, unit.source ?? null, unit.owners ?? []); + }, + check: (name, stored, pending, unit) => + verifyStoredUnit(name, stored, pending, unit.record, unit.reasons), + note: (name, record, applied, files) => { + const file = fileOfUnit(name); + if (file !== null) reuse.note(name, file, record, applied, files); + }, + }; const recordLines: string[] = []; const drainUnits = (): void => { + // The waves of one drain have only checker prefetches between them, so + // they share the content their units start from (HeldTables). + const held = + host !== null && recorded !== null ? new HeldTables(host.tables, recorded) : null; while (queue.length > 0) { const wave = queue.splice(0); shuffle?.(wave); this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots)); if (host !== null && recorded !== null) { - lowerWaveRecorded(host, wave, recorded, dump === null ? null : recordLines); + lowerWaveRecorded( + host, + wave, + recorded, + dump === null ? null : recordLines, + waveReuse, + held, + ); continue; } for (const name of wave) { @@ -10447,6 +10546,7 @@ export class Lowerer { } } } + held?.check(); }; drainUnits(); this.restoreGenericInstanceOrder(); @@ -10491,6 +10591,13 @@ export class Lowerer { } classDispatch.finalize(this); if (dump !== null) writeRecords(dump, recordLines); + if (reuse !== null) { + reuse.finish(); + if (process.env["SCRIPTC_TIMING"] === "1") + process.stderr.write( + `scriptc lowering ${JSON.stringify({ phase: "unit-reuse", ...reuse.stats })}\n`, + ); + } if (recorded !== null && process.env["SCRIPTC_TIMING"] === "1") process.stderr.write( `scriptc lowering ${JSON.stringify({ phase: "unit-records", ...recorded })}\n`, @@ -10539,11 +10646,57 @@ export class Lowerer { return { reachable, result: this.finishModule(functions) }; } + /** What the steps before the worklist settled for a top-level + * declaration, as text without source positions (unit-reuse.ts): a + * function's signature, a class's layout and the signatures of its + * methods and statics, and the globals of a variable statement. */ + private declarationFacts(declaration: ts.Node): string { + const positionFree = (key: string, value: unknown): unknown => + key === "loc" || key === "scope" ? undefined : value; + if (ts.isFunctionDeclaration(declaration)) { + const symbol = declSymbolOf(this, declaration); + const sig = symbol ? this.fnSigsBySymbol.get(symbol) : undefined; + return sig === undefined ? "" : JSON.stringify(sig, positionFree); + } + if (ts.isClassDeclaration(declaration)) { + const info = this.classes.get(this.classNamer(declaration)); + if (info === undefined) return ""; + const staticMethods: { name: string; params: ParamShape[]; ret: IrType }[] = []; + if (info.staticMethods) + for (const [name, method] of info.staticMethods) + staticMethods.push({ name, params: method.params, ret: method.ret }); + return JSON.stringify( + { + def: info.def, + fields: [...info.fields], + methods: [...info.methods], + statics: info.staticFields.map((field) => ({ + name: field.name, + type: field.type, + runtimeOptional: field.runtimeOptional ?? false, + globalId: field.globalId, + readonly: field.readonly, + })), + staticMethods, + }, + positionFree, + ); + } + if (ts.isVariableStatement(declaration)) { + const globals: (IrGlobal | null)[] = []; + for (const decl of declaration.declarationList.declarations.nodes) + globals.push(this.globalsByDeclNode.get(decl) ?? null); + return JSON.stringify(globals, positionFree); + } + return ""; + } + /** The lowerer's side of a recorded wave (unit-journal.ts). * `lowerUnitBody` lowers a unit as reachable emit does. */ private waveHost( loweredUnits: Map, lowerUnitBody: (name: string) => IrFunction | null, + ownerOf: (name: string) => { owner: GenericDemandOwner; priority: readonly [number, number] }, ): WaveHost { const scalars = this.journalScalars(); const flags = new Map(scalars.map((scalar) => [scalar.name, scalar])); @@ -10574,6 +10727,35 @@ export class Lowerer { return undefined; } }; + // Makes a recorded call again (resolves has answered true for it). + const replay = (event: JournalEvent): void => { + switch (event.kind) { + case "edge": + this.noteEdge(event.name); + break; + case "superConstructorEdge": + this.noteSuperConstructorEdge(event.name); + break; + case "virtualEdge": + this.noteVirtualEdge(classNamed(event.name)!, event.detail!); + break; + case "functionDemand": + this.noteGenericInstanceDemand(instanceNamed(event.name)!); + break; + case "classDemand": + this.noteGenericClassInstanceDemand(classNamed(event.name)!); + break; + case "statement": + this.countStatement(statementNode(event)!, event.detail ?? undefined); + break; + case "failedStatement": + this.countStatementFailed(statementNode(event)!, event.detail ?? undefined); + break; + case "flag": + flags.get(event.name)!.set!(); + break; + } + }; return { tables: this.journalTables(), scalars, @@ -10614,6 +10796,15 @@ export class Lowerer { return true; } }, + keep: (name, fn, events) => { + if (fn) loweredUnits.set(name, fn); + const { owner, priority } = ownerOf(name); + this.withGenericDemandOwner(owner, () => + this.shapes.withDeclaredOrderPriority(priority, () => { + for (const event of events) replay(event); + }), + ); + }, }; } diff --git a/packages/compiler/src/frontend/lowering/unit-journal.test.ts b/packages/compiler/src/frontend/lowering/unit-journal.test.ts index 3b9599d7b..55b030c2f 100644 --- a/packages/compiler/src/frontend/lowering/unit-journal.test.ts +++ b/packages/compiler/src/frontend/lowering/unit-journal.test.ts @@ -4,6 +4,7 @@ import { Lowerer } from "./lowerer.js"; import { checkedTable, contentDigest, + decodeRecordFunction, encodeUnitFunction, flagScalar, functionTable, @@ -12,7 +13,9 @@ import { recordUnit, setTable, waveStats, + type UnitRecord, type WaveHost, + type WaveReuse, } from "./unit-journal.js"; import type { IrFunction } from "../../ir/ir.js"; @@ -225,6 +228,7 @@ function host(lower: (name: string, state: HostState) => IrFunction | null): { }, statementCounts: () => ({ total: 0, failed: 0, other: 0 }), resolves: () => true, + keep: () => {}, }, }; } @@ -276,7 +280,10 @@ test("a unit's record lists its events, flags and added entries", () => { ["flag", "flag"], ]); expect(record!.tables).toEqual({ helpers: [`"key.a"\n"%h.a"`] }); - expect(JSON.parse(JSON.stringify(record))).toEqual(record); + // The function is in the binary encoding, and the rest is JSON data. + const { fn: bytes, ...rest } = record!; + expect(bytes).toBeInstanceOf(Uint8Array); + expect(JSON.parse(JSON.stringify(rest))).toEqual(rest); }); test("a unit that changes an entry it did not add has no record", () => { @@ -303,6 +310,7 @@ test("a recorded wave throws when a unit changes a held function in place", () = store: () => {}, statementCounts: () => ({ total: 0, failed: 0, other: 0 }), resolves: () => true, + keep: () => {}, }; lowerWaveRecorded(subject, ["a"], waveStats()); expect(() => lowerWaveRecorded(subject, ["a", "b"], waveStats())).toThrow( @@ -310,6 +318,47 @@ test("a recorded wave throws when a unit changes a held function in place", () = ); }); +test("a recorded wave holds the entries an applied record adds after its first lowering unit", () => { + // `first` lowers, which takes the wave's content; `a` applies a stored + // record that adds `%added`; `b` changes `%added` in place without naming + // it, which the end of the wave must see. + const functions: IrFunction[] = []; + const subject: WaveHost = { + tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))], + scalars: [], + lowerRecorded: (name) => { + if (name === "b") + functions + .find((f) => f.name === "%added")! + .locals.push({ id: "x.0", name: "x", type: { kind: "f64" } } as never); + return { fn: fn(`%${name}`), poisoned: false }; + }, + store: () => {}, + statementCounts: () => ({ total: 0, failed: 0, other: 0 }), + resolves: () => true, + keep: () => {}, + }; + const stored: UnitRecord = { + name: "a", + fn: null, + poisoned: false, + events: [], + tables: { lifted: [encodeUnitFunction(fn("%added"))!] }, + reads: {}, + }; + const reuse: WaveReuse = { + stored: (name) => (name === "a" ? { record: stored, fn: () => null } : null), + verify: false, + check: () => {}, + note: () => {}, + }; + const stats = waveStats(); + expect(() => lowerWaveRecorded(subject, ["first", "a", "b"], stats, null, reuse)).toThrow( + "a unit changed lifted entry %added in place without naming it", + ); + expect(stats.applied).toBe(1); +}); + /** A wave host over one function table holding `%shared` and one checked * list, whose units change `%shared` in place and name it in their IR. */ function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked: string[] } { @@ -334,6 +383,7 @@ function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked store: () => {}, statementCounts: () => ({ total: 0, failed: 0, other: 0 }), resolves: () => true, + keep: () => {}, }, }; } @@ -352,6 +402,40 @@ test("a unit that changes a function it names in place has no record", () => { expect(stats).toMatchObject({ recorded: 0, unrecorded: 1, reasons: { "lifted:changed": 1 } }); }); +test("a record's binary function names the held functions its strings name", () => { + const functions = [fn("%shared")]; + const loc = { file: "b.ts", start: 0, end: 0 }; + const subject: WaveHost = { + tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))], + scalars: [], + lowerRecorded: () => ({ + fn: { + ...fn("%a"), + body: [ + { + kind: "exprStmt", + expr: { kind: "call", callee: "%shared", args: [], type: { kind: "f64" }, loc }, + loc, + }, + ] as never, + }, + poisoned: false, + }), + store: () => {}, + statementCounts: () => ({ total: 0, failed: 0, other: 0 }), + resolves: () => true, + keep: () => {}, + }; + const unit = recordUnit(subject, "a"); + expect(unit.record!.fn).toBeInstanceOf(Uint8Array); + expect(unit.record!.reads).toEqual({ + lifted: [["%shared", contentDigest(encodeUnitFunction(functions[0]!)!)]], + }); + // The files of the function's source positions, for reuse to check. + expect([...unit.files!].sort()).toEqual(["a.ts", "b.ts"]); + expect(decodeRecordFunction(unit.record!.fn!)).toEqual(unit.fn); +}); + test("a record lists the held functions it names, with their digests", () => { const functions = [fn("%shared"), fn("%unused")]; const subject: WaveHost = { @@ -373,6 +457,7 @@ test("a record lists the held functions it names, with their digests", () => { store: () => {}, statementCounts: () => ({ total: 0, failed: 0, other: 0 }), resolves: () => true, + keep: () => {}, }; const { record } = recordUnit(subject, "a"); // The function the unit added is in its entries, not its reads. diff --git a/packages/compiler/src/frontend/lowering/unit-journal.ts b/packages/compiler/src/frontend/lowering/unit-journal.ts index e349bb2cf..68bd3e778 100644 --- a/packages/compiler/src/frontend/lowering/unit-journal.ts +++ b/packages/compiler/src/frontend/lowering/unit-journal.ts @@ -4,8 +4,9 @@ * unit's lowering reached: the unit's IR, the entries it added to the * tables that collect lowering results (Lowerer.journalTables), and the * calls it made into reachability, generic demand and statement counting - * (JournalEvent). Records hold only strings, numbers and names, so they can - * be written to a file and read by another process or another build. + * (JournalEvent). Records hold only the function's bytes (or its JSON), + * strings, numbers and names, so they can be written to a file and read by + * another process or another build. * * A unit has no record when its lowering changed state that no record * carries: a table that is not appendable grew, a field changed, an entry @@ -23,20 +24,59 @@ import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { InternalCompilerError } from "../../errors.js"; +import { decodeFunctionBinary, encodeFunctionStrings, functionDigest } from "../../ir/codec.js"; import type { IrFunction } from "../../ir/ir.js"; import { deserializeFunction, encodeFunction } from "../../ir/serialize.js"; -/** A function as records hold it, or null when it does not encode. Every - * function a record holds goes through this pair. */ +/** A function as table entries hold it, or null when it does not encode. + * Every function an entry holds goes through this pair. */ export function encodeUnitFunction(fn: IrFunction): string | null { return encodeFunction(fn) ?? null; } -/** A function from its record encoding (encodeUnitFunction). */ +/** A function from its entry encoding (encodeUnitFunction). */ export function decodeUnitFunction(text: string): IrFunction { return deserializeFunction(text); } +/** A unit's function as its record holds it: the binary IR encoding, or + * JSON (encodeUnitFunction) when an object in it would not read back with + * its own key order, which frontend passes and the IR's text depend on. */ +export type RecordFunction = string | Uint8Array; + +/** `fn` as a record holds it, or null when it does not encode. A binary + * encoding comes with its strings and the files its fields named `file` + * hold (encodeFunctionStrings). */ +export function encodeRecordFunction(fn: IrFunction): { + readonly fn: RecordFunction; + readonly strings: readonly string[] | null; + readonly files: readonly string[] | null; +} | null { + const binary = encodeFunctionStrings(fn); + if (binary !== null) return { fn: binary.bytes, strings: binary.strings, files: binary.files }; + const text = encodeUnitFunction(fn); + return text === null ? null : { fn: text, strings: null, files: null }; +} + +/** A function from its record encoding (encodeRecordFunction). */ +export function decodeRecordFunction(fn: RecordFunction): IrFunction { + return typeof fn === "string" + ? decodeUnitFunction(fn) + : decodeFunctionBinary(Buffer.from(fn.buffer, fn.byteOffset, fn.byteLength)); +} + +/** `record` as a JSON line, with a binary function in base64, marked when + * it was `applied` from an earlier build's store. */ +export function recordLine(record: UnitRecord, applied = false): string { + const fn = + record.fn === null || typeof record.fn === "string" + ? record.fn + : Buffer.from(record.fn.buffer, record.fn.byteOffset, record.fn.byteLength).toString( + "base64", + ); + return JSON.stringify(applied ? { ...record, fn, applied: true } : { ...record, fn }); +} + /** A call a unit made while it lowered, which applying its record makes * again. */ export interface JournalEvent { @@ -62,9 +102,9 @@ export interface JournalEvent { /** One unit's record. */ export interface UnitRecord { readonly name: string; - /** The unit's function in the compact IR encoding, or null when the unit + /** The unit's function (encodeRecordFunction), or null when the unit * lowered to nothing. */ - readonly fn: string | null; + readonly fn: RecordFunction | null; /** True when the unit's lowering ended in a poison: its diagnostics and * the edges it fired before poisoning stay, and it has no function. */ readonly poisoned: boolean; @@ -91,6 +131,12 @@ export interface JournalTable { readonly entries?: (from: number) => string[] | null; /** Adds the encoded entries whose keys this process's table lacks. */ readonly apply?: (entries: readonly string[]) => void; + /** Whether every encoded entry whose key this process's table holds + * holds the same value there. */ + readonly agrees?: (entries: readonly string[]) => boolean; + /** Whether this process's table holds an entry under the encoded entry's + * key. */ + readonly holds?: (entry: string) => boolean; /** A digest of the content of the entry under `key`, when the table * holds one before position `before`; null otherwise. */ readonly digest?: (key: string, before: number) => string | null; @@ -125,10 +171,20 @@ export function contentDigest(text: string): string { return `${text.length}:${(a >>> 0).toString(16)}${(b >>> 0).toString(16)}`; } -/** The names quoted in JSON `texts`, unescaped only as far as their - * delimiting quotes: the strings that start with `%` (helper, lifted and - * IR data names) and union ids (`u` and 12 hex digits), which the tables - * a unit reads and changes are keyed by. */ +/** Adds to `out` the names among `strings`: the strings that start with `%` + * (helper, lifted and IR data names) and union ids (`u` and 12 hex digits), + * which the tables a unit reads and changes are keyed by. */ +export function namedStrings(strings: Iterable, out: Set): void { + for (const text of strings) { + const first = text.charCodeAt(0); + if (first === 37 /* % */) out.add(text); + else if (first === 117 /* u */ && text.length === 13 && /^u[0-9a-f]{12}$/.test(text)) + out.add(text); + } +} + +/** The names (namedStrings) quoted in JSON `texts`, unescaped only as far + * as their delimiting quotes. */ export function quotedStrings(texts: readonly string[]): Set { const out = new Set(); const hex = (char: number): boolean => (char >= 48 && char <= 57) || (char >= 97 && char <= 102); @@ -225,6 +281,10 @@ export interface WaveHost { statementCounts(): { total: number; failed: number; other: number }; /** Whether `event` names something this process holds. */ resolves(event: JournalEvent): boolean; + /** Keeps a unit's function from its record and makes the record's events + * again, under the unit's generic demand owner and declared-order + * priority. */ + keep(name: string, fn: IrFunction | null, events: readonly JournalEvent[]): void; } /** Whether reachable emit records its units (SCRIPTC_LOWER_JOURNAL=serial, @@ -259,10 +319,69 @@ export interface WaveStats { unrecorded: number; /** Per table, field or check, the units it left without a record. */ reasons: Record; + /** Units whose stored record applied in place of lowering them + * (unit-reuse.ts). */ + applied: number; + /** Time taking and checking the tables' contents, finding stored records, + * applying them, and lowering and recording units, in milliseconds. */ + holdMs: number; + storedMs: number; + applyMs: number; + recordMs: number; + /** Of recordMs, the time the units' lowering took, and the time a + * recorded unit's results took to be replaced by its record's. */ + lowerMs: number; + replaceMs: number; } export function waveStats(): WaveStats { - return { waves: 0, units: 0, recorded: 0, unrecorded: 0, reasons: {} }; + return { + waves: 0, + units: 0, + recorded: 0, + unrecorded: 0, + reasons: {}, + applied: 0, + holdMs: 0, + storedMs: 0, + applyMs: 0, + recordMs: 0, + lowerMs: 0, + replaceMs: 0, + }; +} + +/** A wave's use of the records an earlier build stored (unit-reuse.ts). */ +/** A record an earlier build stored, with its function, which the store + * can keep apart from the record (`record.fn` is null then). */ +export interface StoredRecord { + readonly record: UnitRecord; + fn(): IrFunction | null; +} + +export interface WaveReuse { + /** The stored record of `name`, when the unit can apply it. */ + stored(name: string): StoredRecord | null; + /** Whether units with a stored record lower and are checked against it + * instead (SCRIPTC_INCR_VERIFY=1). */ + readonly verify: boolean; + /** Checks the record a unit lowered to against its stored one, whose + * entries the tables did not hold yet at the unit's turn are `pending`. */ + check( + name: string, + stored: StoredRecord, + pending: Readonly>, + unit: RecordedUnit, + ): void; + /** Notes the record a unit lowered to, with the files its binary + * function names (RecordedUnit.files), or the stored record it applied + * (`files` null). */ + note( + name: string, + record: UnitRecord | null, + applied: boolean, + files: readonly string[] | null, + ): void; } /** What lowering one unit with its events recording produced. */ @@ -275,6 +394,11 @@ export interface RecordedUnit { readonly fn: IrFunction | null; /** Each table's size before the unit. */ readonly before: readonly number[]; + /** The time the unit's lowering took. */ + readonly lowerMs: number; + /** The files the fields named `file` in the record's function hold, when + * the function is binary. */ + readonly files: readonly string[] | null; } /** Lowers `name` with its events recording, and makes its record. The unit @@ -297,7 +421,9 @@ export function recordUnit( const marks = host.scalars.map((scalar) => scalar.mark()); const counts = host.statementCounts(); const recorder = new UnitEvents(); + const lowering = performance.now(); const { fn, poisoned } = host.lowerRecorded(name, recorder); + const lowerMs = performance.now() - lowering; const reasons: string[] = []; const grown: number[] = []; tables.forEach((table, index) => { @@ -322,8 +448,8 @@ export function recordUnit( if (now.total - counts.total !== statements || now.failed - counts.failed !== failed) reasons.push("statements"); if (now.other !== counts.other) reasons.push("diagnostics"); - const text = fn === null ? null : encodeUnitFunction(fn); - if (fn !== null && text === null) reasons.push("encoding"); + const encoded = fn === null ? null : encodeRecordFunction(fn); + if (fn !== null && encoded === null) reasons.push("encoding"); const added: Record = {}; if (reasons.length === 0) for (const index of grown) { @@ -335,13 +461,14 @@ export function recordUnit( // A unit uses the entries its IR and new entries name: it reads them, and // a unit that changes one in place changes one it names. const texts: string[] = []; - if (text !== null) texts.push(text); + if (typeof encoded?.fn === "string") texts.push(encoded.fn); for (const index of grown) { const table = tables[index]!; const entries = added[table.name] ?? table.entries!(before[index]!); if (entries !== null) for (const entry of entries) texts.push(entry); } const names = quotedStrings(texts); + if (encoded?.strings != null) namedStrings(encoded.strings, names); if (own !== null) { if (reasons.length === 0) tables.forEach((table, index) => { @@ -369,36 +496,72 @@ export function recordUnit( } if (read.length > 0) reads[table.name] = read; }); - if (reasons.length > 0) return { record: null, reasons, fn, before }; + if (reasons.length > 0) return { record: null, reasons, fn, before, lowerMs, files: null }; return { - record: { name, fn: text, poisoned, events, tables: added, reads }, + record: { name, fn: encoded?.fn ?? null, poisoned, events, tables: added, reads }, reasons, fn, before, + lowerMs, + files: encoded?.files ?? null, }; } -/** Lowers `wave` with every unit recorded. A unit with a record keeps the - * results its record carries: the entries it added are taken out of their - * tables and added again from the record, and its function is the one - * decoded from the record. A unit without one keeps what lowering made. +/** Lowers `wave` with every unit recorded. Without `reuse`, a unit with a + * record keeps the results its record carries: the entries it added are + * taken out of their tables and added again from the record, and its + * function is the one decoded from the record. Otherwise, and for a unit + * without a record, a unit keeps what lowering made. * Throws an InternalCompilerError when a unit changed the content of an * entry a table held when the unit started (JournalTable.contents), which * no record carries. - * `lines`, when given, receives each unit's record as a JSON line. */ + * `lines`, when given, receives each unit's record as a JSON line. A unit + * whose stored record `reuse` gives applies it, and lowers when it does not + * apply. With `held`, the wave continues the content the waves before it + * left (HeldTables), and the check waits for HeldTables.check. */ export function lowerWaveRecorded( host: WaveHost, wave: readonly string[], stats: WaveStats, lines: string[] | null = null, + reuse: WaveReuse | null = null, + held: HeldTables | null = null, ): void { stats.waves++; const tables = host.tables; - // Units keep the content of the entries that tables held when they - // started: a record carries a unit's new entries, not changes to others. - const contents = tables.map((table) => (table.contents === undefined ? null : table.contents())); + const contents = held ?? new HeldTables(tables, stats); + let started = 0; for (const name of wave) { - const unit = recordUnit(host, name, contents); + started = performance.now(); + let stored = reuse?.stored(name) ?? null; + stats.storedMs += performance.now() - started; + let pending: Readonly> = {}; + if (stored !== null && reuse!.verify) { + // Verify mode lowers the units whose stored record would apply and + // checks the record they lower to. + if (recordApplies(host, stored.record).length === 0) + pending = pendingEntries(host, stored.record); + else stored = null; + } else if (stored !== null) { + started = performance.now(); + const reasons = applyRecord(host, stored.record, stored.fn); + stats.applyMs += performance.now() - started; + if (reasons.length === 0) { + stats.units++; + stats.recorded++; + stats.applied++; + contents.extendTaken(); + lines?.push(recordLine(stored.record, true)); + reuse!.note(name, stored.record, true, null); + continue; + } + for (const reason of reasons) + stats.reasons[`stored:${reason}`] = (stats.reasons[`stored:${reason}`] ?? 0) + 1; + } + started = performance.now(); + const unit = recordUnit(host, name, contents.take()); + stats.recordMs += performance.now() - started; + stats.lowerMs += unit.lowerMs; stats.units++; const record = unit.record; if (record === null) { @@ -406,43 +569,174 @@ export function lowerWaveRecorded( stats.unrecorded++; for (const reason of unit.reasons) stats.reasons[reason] = (stats.reasons[reason] ?? 0) + 1; host.store(name, unit.fn); + if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit); + reuse?.note(name, null, false, null); continue; } - lines?.push(JSON.stringify(record)); + lines?.push(recordLine(record)); stats.recorded++; - tables.forEach((table, index) => { - const entries = record.tables[table.name]; - if (entries === undefined) return; - table.truncate!(unit.before[index]!); - table.apply!(entries); - }); - host.store(name, record.fn === null ? null : decodeUnitFunction(record.fn)); + // The serial mode replaces a unit's results by its record's, so that the + // build's output is what applying the records gives. Reuse keeps what + // lowering made: its records apply in later builds, which verify mode + // and the edit-sequence tests check. + let fn = unit.fn; + if (reuse === null) { + const replacing = performance.now(); + tables.forEach((table, index) => { + const entries = record.tables[table.name]; + if (entries === undefined) return; + table.truncate!(unit.before[index]!); + table.apply!(entries); + }); + fn = record.fn === null ? null : decodeRecordFunction(record.fn); + stats.replaceMs += performance.now() - replacing; + } + host.store(name, fn); + if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit); + reuse?.note(name, record, false, unit.files); } - tables.forEach((table, index) => { - const key = contents[index]?.changed() ?? null; - if (key !== null) - throw new InternalCompilerError( - `a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`, + if (held === null) contents.check(); +} + +/** The content of the tables' entries that recorded units start from + * (JournalTable.contents): units keep the content of the entries the + * tables held when they started, since a record carries a unit's new + * entries, not changes to others. Applying a stored record only adds + * entries, so the content is taken when the first unit lowers, and not at + * all when none does. Consecutive waves with nothing but checker prefetches + * between them share one: the content a wave leaves, as the check finds it, + * is the content the next one starts with. */ +export class HeldTables { + private contents: (HeldContents | null)[] | null = null; + + constructor( + private readonly tables: readonly JournalTable[], + private readonly stats: WaveStats, + ) {} + + /** The held content, taken now when it was not yet. */ + take(): (HeldContents | null)[] { + if (this.contents === null) { + const taken = performance.now(); + this.contents = this.tables.map((table) => + table.contents === undefined ? null : table.contents(), ); - }); + this.stats.holdMs += performance.now() - taken; + } + return this.contents; + } + + /** Adds the entries past the held ones, when the content is taken. */ + extendTaken(): void { + if (this.contents !== null) for (const entry of this.contents) entry?.extend(); + } + + /** Throws an InternalCompilerError when an entry a table held changed in + * place since the content was taken, other than by the units that keep + * their changes (HeldContents.refresh). */ + check(): void { + const contents = this.contents; + if (contents === null) return; + const started = performance.now(); + this.tables.forEach((table, index) => { + const key = contents[index]?.changed() ?? null; + if (key !== null) + throw new InternalCompilerError( + `a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`, + ); + }); + this.stats.holdMs += performance.now() - started; + } +} + +/** Applies `record` in place of lowering its unit, at the unit's turn, or + * returns why the unit has to lower instead. A record applies when every + * entry it adds agrees with what the tables hold, it names only known + * tables, every event resolves and every entry it reads is held with the + * same content. Applying adds its entries, keeps its function (`stored`'s, + * when the store keeps it apart from the record) and makes its events + * again. */ +export function applyRecord( + host: WaveHost, + record: UnitRecord, + stored?: () => IrFunction | null, +): string[] { + const reasons = recordApplies(host, record); + if (reasons.length > 0) return reasons; + for (const table of host.tables) { + const entries = record.tables[table.name]; + if (entries !== undefined) table.apply!(entries); + } + const fn = record.poisoned + ? null + : stored !== undefined + ? stored() + : record.fn === null + ? null + : decodeRecordFunction(record.fn); + host.keep(record.name, fn, record.events); + return []; +} + +/** The entries `record` adds that the tables do not hold yet, by table + * name: what applying it would add at its unit's turn. */ +export function pendingEntries( + host: WaveHost, + record: UnitRecord, +): Record { + const out: Record = {}; + for (const table of host.tables) { + const entries = record.tables[table.name]; + if (entries === undefined) continue; + const pending = entries.filter((entry) => table.holds?.(entry) !== true); + if (pending.length > 0) out[table.name] = pending; + } + return out; +} + +/** Why `record` does not apply at its unit's turn (applyRecord), or no + * reasons when it does. */ +export function recordApplies(host: WaveHost, record: UnitRecord): string[] { + const tables = host.tables; + for (const table of tables) { + const entries = record.tables[table.name]; + if (entries === undefined) continue; + if (table.agrees === undefined || table.apply === undefined || !table.agrees(entries)) + return [`disagrees:${table.name}`]; + } + for (const table of Object.keys(record.tables)) + if (!tables.some((held) => held.name === table)) return [`unknown table:${table}`]; + for (const event of record.events) if (!host.resolves(event)) return [`event:${event.kind}`]; + for (const table of tables) { + const reads = record.reads[table.name]; + if (reads === undefined) continue; + if (table.digest === undefined) return [`unknown read:${table.name}`]; + const size = table.size(); + for (const [key, digest] of reads) + if (table.digest(key, size) !== digest) return [`read:${table.name}`]; + } + for (const table of Object.keys(record.reads)) + if (!tables.some((held) => held.name === table)) return [`unknown read:${table}`]; + return []; } /** The content of a keyed table's entries (JournalTable.contents): each - * entry's encoded value, and the order of the keys, since a table only adds - * entries at the end. An entry is found by its key, or by the name its value - * holds (`valueName`): a unit names the helper it got, not the key it was - * looked up by. `text` answers undefined for a key the table does not + * entry's content (`text`: its encoded value, or the value itself when it + * is a string or a number), and the order of the keys, since a table only + * adds entries at the end. An entry is found by its key, or by the name its + * value holds (`valueName`): a unit names the helper it got, not the key it + * was looked up by. `text` answers undefined for a key the table does not * hold. */ function keyedContents( size: () => number, keys: () => Iterable, - text: (key: string) => string | null | undefined, + text: (key: string) => string | number | null | undefined, valueName: (key: string) => string | null, ): HeldContents { - const texts = new Map(); + const texts = new Map(); let order: string[] = []; const byName = new Map(); - const differs = (key: string): boolean => text(key) !== texts.get(key); + const differs = (key: string): boolean => !Object.is(text(key), texts.get(key)); const keyOf = (name: string): string | undefined => (texts.has(name) ? name : byName.get(name)); const held: HeldContents = { extend: () => { @@ -499,6 +793,7 @@ export function mapTable(name: string, map: Map): JournalTable { (value) => JSON.stringify(value), (text) => JSON.parse(text) as string, (value) => value, + (value) => value, ); } @@ -509,6 +804,8 @@ export function countTable(name: string, map: Map): JournalTable map, (value) => String(value), (text) => Number(text), + null, + (value) => value, ); } @@ -530,13 +827,17 @@ export function irDataTable(name: string, map: Map): JournalTable /** An appendable table over a Map from strings to values that `encode` * writes, or answers null for, and `decode` reads back. An entry is its key * as a JSON string, a line break and its value: a JSON string holds no line - * break. A value that does not encode leaves its unit without a record. */ + * break. A value that does not encode leaves its unit without a record. + * `content` is what held contents compare (JournalTable.contents): the + * encoding, or the value itself for a table of strings or numbers, which + * only change by being replaced. */ export function codecTable( name: string, map: Map, encode: (value: V) => string | null, decode: (text: string) => V, nameOf: ((value: V) => string) | null = null, + content: (value: V) => string | number | null = encode, ): JournalTable { return { name, @@ -552,6 +853,13 @@ export function codecTable( } return out; }, + agrees: (entries) => + entries.every((entry) => { + const split = entry.indexOf("\n"); + const held = map.get(JSON.parse(entry.slice(0, split)) as string); + return held === undefined || encode(held) === entry.slice(split + 1); + }), + holds: (entry) => map.has(JSON.parse(entry.slice(0, entry.indexOf("\n"))) as string), apply: (entries) => { for (const entry of entries) { const split = entry.indexOf("\n"); @@ -569,7 +877,7 @@ export function codecTable( () => map.keys(), (key) => { const value = map.get(key); - return value === undefined ? undefined : encode(value); + return value === undefined ? undefined : content(value); }, (key) => { const value = map.get(key); @@ -585,6 +893,8 @@ export function setTable(name: string, set: Set): JournalTable { name, size: () => set.size, entries: (from) => [...set].slice(from), + agrees: () => true, + holds: (entry) => set.has(entry), apply: (entries) => { for (const entry of entries) set.add(entry); }, @@ -652,6 +962,14 @@ export function functionTable( } return out; }, + agrees: (entries) => + entries.every((entry) => { + const fn = decodeUnitFunction(entry); + if (!has(fn.name)) return true; + const index = indexOf(fn.name); + return index !== undefined && encodeUnitFunction(functions[index]!) === entry; + }), + holds: (entry) => has(decodeUnitFunction(entry).name), apply: (entries) => { for (const entry of entries) { const fn = decodeUnitFunction(entry); @@ -663,16 +981,17 @@ export function functionTable( while (functions.length > from) functions.pop(); changed(); }, + // Held functions are their content digests (heldFunction). contents: () => { const texts: (string | null)[] = []; const indexes = new Map(); const differs = (index: number): boolean => - index < functions.length && encodeUnitFunction(functions[index]!) !== texts[index]; + index < functions.length && heldFunction(functions[index]!) !== texts[index]; const held: HeldContents = { extend: () => { for (let index = texts.length; index < functions.length; index++) { const fn = functions[index]!; - texts.push(encodeUnitFunction(fn)); + texts.push(heldFunction(fn)); indexes.set(fn.name, index); } }, @@ -692,7 +1011,7 @@ export function functionTable( for (const key of keys) { const index = indexes.get(key); if (index !== undefined && index < functions.length) - texts[index] = encodeUnitFunction(functions[index]!); + texts[index] = heldFunction(functions[index]!); } }, }; @@ -702,6 +1021,17 @@ export function functionTable( }; } +/** The content a function table holds of `fn` (HeldContents): its digest + * (functionDigest), or its entry encoding when the binary encoder cannot + * write it. */ +function heldFunction(fn: IrFunction): string | null { + try { + return functionDigest(fn); + } catch { + return encodeUnitFunction(fn); + } +} + /** A table whose growth leaves the unit without a record. */ export function checkedTable(name: string, size: () => number): JournalTable { return { name, size }; diff --git a/packages/compiler/src/frontend/lowering/unit-reuse.ts b/packages/compiler/src/frontend/lowering/unit-reuse.ts new file mode 100644 index 000000000..5de2c7b0a --- /dev/null +++ b/packages/compiler/src/frontend/lowering/unit-reuse.ts @@ -0,0 +1,1030 @@ +/* Reuse of lowered units across builds (SCRIPTC_INCR_LOWERING=1). + * + * A build with a unit store records every reachable-emit unit + * (unit-journal.ts) and writes the records to the store, one entry per + * source file, beside a program entry that describes the build's program. + * The next build applies a unit's stored record in place of lowering the + * unit when nothing the unit's lowering read can have changed: + * + * - the program digest is unchanged: the IR version, the files outside the + * program's own modules (declaration files, the library), the module + * order and the statements with global effect; + * - the unit's source file has the same text, and so does every other file + * whose source positions its record holds; + * - the unit's text spells no name in the build's changed names. + * + * The changed names come from the two builds' declaration tables. A + * declaration is a top-level statement or a member of a top-level class or + * interface. Its surface is its text without the bodies of functions that + * declare their return type: what other declarations' types can depend on. + * Its facts are what the steps before the worklist settled for it: a + * function's signature, a class's layout and method signatures, a + * variable's globals. The changed names are: + * + * - the names of top-level declarations that were added or removed, or + * whose surface or facts changed, and the interfaces whose members did, + * closed over the surfaces that spell one of them (a declaration whose + * surface spells one joins); + * - the names of such class and interface members, and of declarations + * whose text alone changed; + * - the names a changed, added or removed declaration writes to outside + * itself, since analyses read a binding's or property's writes anywhere; + * - the names that refer to a module's namespace object, by a namespace + * import or an `import()` or `require()` call, when the module's exports + * changed. + * + * TypeScript resolves every dependency of a type through a name some text + * spells, so a unit whose text spells none of these sees the same types, + * the same declarations and the same facts. A unit's spelled names include + * its strings, which can name members. + * + * The first build into a cache records nothing: it writes only the + * declaration tables, and the build after it records its units. + * + * Applying a record goes through applyRecord, which still lowers the unit + * when the record disagrees with what this build holds by the unit's turn. + * SCRIPTC_INCR_VERIFY=1 lowers every unit whose record would apply and + * throws an InternalCompilerError naming the unit when the new record + * differs from the stored one. */ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { InternalCompilerError } from "../../errors.js"; +import type { IrFunction } from "../../ir/ir.js"; +import { IrReader, IrWriter, decodeFunctionBinary } from "../../ir/codec.js"; +import { IR_CODEC_LAYOUT } from "../../ir/codec.generated.js"; +import { IR_VERSION } from "../../ir/serialize.js"; +import * as ts from "../ts7/adapter.js"; +import { isJsSourceFileName } from "../tsc-codes.js"; +import { + decodeRecordFunction, + encodeUnitFunction, + type StoredRecord, + type UnitRecord, +} from "./unit-journal.js"; + +/** Where a build's unit entries live. The store's owner keys the program + * entry by everything the lowered module depends on besides the program's + * sources: the compiler, the entry, the options and the target. */ +export interface UnitStore { + /** The program entry the last build with the same key wrote, or null. */ + readProgram(): Uint8Array | null; + writeProgram(bytes: Uint8Array): void; + /** A file entry by the name writeFile gave it, or null. */ + readFile(name: string): Uint8Array | null; + /** Writes a file entry and returns its name. */ + writeFile(bytes: Uint8Array): string; +} + +/** What reuse asks the lowerer. */ +export interface UnitReuseHooks { + /** The facts the steps before the worklist settled for a declaration (a + * top-level statement or a class's header), as text. */ + facts(declaration: ts.Node): string; + /** The names a module specifier's module exports, as text, or null when + * it names no module of the program. */ + exportsOf(specifier: ts.Node): string | null; + /** Whether every declaration of the binding `identifier` names is inside + * `container`. */ + isLocal(identifier: ts.Identifier, container: ts.Node): boolean; +} + +/** A unit store in `directory`: the program entry for `key` under + * `program/`, file entries by content digest under `files/`. Failing reads + * are misses and failing writes leave the store as it was. */ +export function directoryUnitStore(directory: string, key: string): UnitStore { + const read = (path: string): Uint8Array | null => { + try { + return readFileSync(path); + } catch { + return null; + } + }; + const write = (path: string, bytes: Uint8Array): void => { + try { + mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 }); + const stage = `${path}.${process.pid}.tmp`; + writeFileSync(stage, bytes); + renameSync(stage, path); + } catch { + // A failed write only costs the next build its reuse. + } + }; + const programPath = join(directory, "program", key); + return { + readProgram: () => read(programPath), + writeProgram: (bytes) => write(programPath, bytes), + readFile: (name) => (/^[0-9a-f]{64}$/.test(name) ? read(join(directory, "files", name)) : null), + writeFile: (bytes) => { + const name = createHash("sha256").update(bytes).digest("hex"); + const path = join(directory, "files", name); + if (read(path) === null) write(path, bytes); + return name; + }, + }; +} + +/** Whether builds reuse lowered units (SCRIPTC_INCR_LOWERING=1). */ +export function unitReuseEnabled(): boolean { + return process.env["SCRIPTC_INCR_LOWERING"] === "1"; +} + +/** Whether every unit whose record would apply lowers again and is checked + * against it (SCRIPTC_INCR_VERIFY=1). */ +export function unitReuseVerify(): boolean { + return process.env["SCRIPTC_INCR_VERIFY"] === "1"; +} + +/** The first line of a program entry and of a file entry. */ +const PROGRAM_MAGIC = "scriptc-units-program-2"; +const FILE_MAGIC = "scriptc-units-file-3"; + +/** A unit's record as an entry holds it, with the files other than the + * unit's own whose source positions the record holds. The function is in + * the binary encoding (`bytes`, the record's `fn` null) when every object + * in it reads back with its own key order, which frontend passes and the + * IR's text depend on, and in the record's JSON otherwise. */ +interface StoredUnit { + readonly record: UnitRecord; + readonly files: readonly string[]; + readonly bytes: Buffer | null; +} + +/** A recorded unit as an entry holds it. `named` are the files the fields + * named `file` in its binary function hold (RecordedUnit.files). */ +function storedUnit(record: UnitRecord, named: readonly string[] | null, own: string): StoredUnit { + const files = positionFiles(record, named, own); + const fn = record.fn; + if (fn === null || typeof fn === "string") return { record, files, bytes: null }; + const bytes = Buffer.from(fn.buffer, fn.byteOffset, fn.byteLength); + return { record: { ...record, fn: null }, files, bytes }; +} + +/** The record of a stored unit, with its function. */ +function storedRecord(unit: StoredUnit): StoredRecord { + const bytes = unit.bytes; + const record = unit.record; + return { + record, + fn: () => + bytes !== null + ? decodeFunctionBinary(bytes) + : record.fn === null + ? null + : decodeRecordFunction(record.fn), + }; +} + +/** The files whose source positions `record` holds, other than `own`: + * those of its JSON function and entries, and `named` for a binary + * function. */ +function positionFiles(record: UnitRecord, named: readonly string[] | null, own: string): string[] { + const files = new Set(); + if (named !== null) for (const file of named) if (file !== own) files.add(file); + const scan = (text: string): void => { + const pattern = /"file":("(?:[^"\\]|\\.)*")/g; + for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) { + const file = JSON.parse(match[1]!) as string; + if (file !== own) files.add(file); + } + }; + if (typeof record.fn === "string") scan(record.fn); + for (const entries of Object.values(record.tables)) for (const entry of entries) scan(entry); + return [...files].sort(); +} + +/** A SHA-256 digest of `text`, in hex. */ +function digestOf(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +/** One declaration of a module's declaration table. */ +interface Declaration { + /** The declaration's kind and name, with an ordinal for repeated ones: + * the same declaration across builds. */ + readonly key: string; + /** The names other text refers to it by. */ + readonly names: readonly string[]; + /** Whether it is a class or interface member: other text reaches it only + * through a value whose type names the owner. */ + readonly member: boolean; + /** For an interface member, the interface: another surface of the member + * is another type of its owner. A class's type as lowering sees it is in + * its header's facts instead. */ + readonly owner: string | null; + /** Digests of its text, its surface and its facts. */ + readonly text: string; + readonly surface: string; + readonly facts: string; + /** The names its text assigns. */ + readonly assigns: readonly string[]; +} + +/** A site that refers to a module's namespace object: the names that hold + * or contain it, and a digest of the module's exports. */ +interface NamespaceSite { + readonly key: string; + readonly names: readonly string[]; + readonly exports: string; +} + +/** What a build records about one of the program's own modules. */ +interface FileTable { + readonly path: string; + readonly text: string; + /** The file entry holding its units' records, or "" when it has none. */ + entry: string; + readonly declarations: readonly Declaration[]; + readonly namespaces: readonly NamespaceSite[]; +} + +/** A declaration of this build's program, for the closure over surfaces: + * the names it declares, its owner's, and its node. A class or interface + * header is its statement without the members. */ +interface DeclarationNode { + readonly names: readonly string[]; + readonly member: boolean; + readonly owner: string | null; + readonly node: ts.Node; + readonly header: boolean; + readonly js: boolean; +} + +/** The body of a function-like node whose body other declarations' types + * cannot see: it declares its return type, or it is a constructor or set + * accessor. Undefined for other nodes. */ +function opaqueBody(node: ts.Node): ts.Node | undefined { + if (!ts.isFunctionLikeDeclaration(node) || node.body === undefined) return undefined; + return node.type !== undefined || ts.isConstructorDeclaration(node) || ts.isSetAccessor(node) + ? node.body + : undefined; +} + +/** The name node of a declaration or member, if it has one. */ +function nameNodeOf(node: ts.Node): ts.Node | undefined { + if ( + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isModuleDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isPropertyDeclaration(node) || + ts.isGetAccessor(node) || + ts.isSetAccessor(node) || + ts.isPropertySignature(node) || + ts.isMethodSignatureDeclaration(node) + ) + return node.name ?? undefined; + return undefined; +} + +/** The kinds the name walks materialize. */ +const NAME_KINDS: ReadonlySet = new Set([ + ts.SyntaxKind.Identifier, + ts.SyntaxKind.PrivateIdentifier, +]); + +/** The kinds of string literals. */ +const STRING_KINDS: ReadonlySet = new Set([ + ts.SyntaxKind.StringLiteral, + ts.SyntaxKind.NoSubstitutionTemplateLiteral, +]); + +/** The kinds that assign to their operand. */ +const WRITE_KINDS: ReadonlySet = new Set([ + ts.SyntaxKind.BinaryExpression, + ts.SyntaxKind.PrefixUnaryExpression, + ts.SyntaxKind.PostfixUnaryExpression, + ts.SyntaxKind.DeleteExpression, +]); + +/** The operators of assignments. */ +const ASSIGNMENTS: ReadonlySet = new Set([ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.PlusEqualsToken, + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, +]); + +/** The identifier names `node`'s text spells, all of them or only those + * outside the bodies opaqueBody names. */ +function spelledNames(node: ts.Node, surfaceOnly: boolean, out: Set): void { + if (!surfaceOnly) { + ts.walkPreorder( + node, + (child) => { + out.add((child as ts.Identifier).text); + }, + NAME_KINDS, + ); + return; + } + ts.walkPreorder(node, (child) => { + if (ts.isIdentifier(child) || ts.isPrivateIdentifier(child)) { + out.add(child.text); + return undefined; + } + const parent = child.parent; + if (parent !== undefined && opaqueBody(parent) === child) return "skip"; + return undefined; + }); +} + +/** The names `node`'s text writes to outside it: the bindings, properties + * and elements that its assignments, `++`, `--` and `delete` target, but + * not bindings declared inside `node`. A property write names the property; + * an element write names what the element's object spells. */ +function assignedNames(node: ts.Node, hooks: UnitReuseHooks): string[] { + const out = new Set(); + const spellsOutside = (expression: ts.Node): void => { + ts.walkPreorder( + expression, + (child) => { + if (ts.isIdentifier(child)) { + if (!hooks.isLocal(child, node)) out.add(child.text); + } else if (ts.isPrivateIdentifier(child)) out.add(child.text); + }, + NAME_KINDS, + ); + }; + const written = (target: ts.Node): void => { + if (ts.isParenthesizedExpression(target)) written(target.expression); + else if (ts.isIdentifier(target)) { + if (!hooks.isLocal(target, node)) out.add(target.text); + } else if (ts.isPropertyAccessExpression(target)) out.add(target.name.text); + else if (ts.isElementAccessExpression(target)) spellsOutside(target.expression); + else if (ts.isObjectLiteralExpression(target)) { + for (const property of target.properties.nodes) { + if (ts.isShorthandPropertyAssignment(property)) written(property.name); + else if (ts.isPropertyAssignment(property)) written(property.initializer); + else if (ts.isSpreadAssignment(property)) written(property.expression); + } + } else if (ts.isArrayLiteralExpression(target)) { + for (const element of target.elements.nodes) + written(ts.isSpreadElement(element) ? element.expression : element); + } else if (ts.isBinaryExpression(target)) written(target.left); + else spellsOutside(target); + }; + ts.walkPreorder( + node, + (child) => { + if (ts.isBinaryExpression(child)) { + if (ASSIGNMENTS.has(child.operatorToken.kind)) written(child.left); + } else if (ts.isPrefixUnaryExpression(child) || ts.isPostfixUnaryExpression(child)) { + if ( + child.operator === ts.SyntaxKind.PlusPlusToken || + child.operator === ts.SyntaxKind.MinusMinusToken + ) + written(child.operand); + } else if (ts.isDeleteExpression(child)) written(child.expression); + }, + WRITE_KINDS, + ); + return [...out].sort(); +} + +/** `node`'s text with the bodies opaqueBody names replaced by `{}`. */ +function surfaceText(sf: ts.SourceFile, node: ts.Node, js: boolean): string { + const text = sf.text; + const start = node.getStart(sf); + if (js) return text.slice(start, node.end); + const cuts: [number, number][] = []; + ts.walkPreorder(node, (child) => { + const body = child === node ? undefined : opaqueBody(child); + if (body === undefined) return undefined; + cuts.push([body.getStart(sf), body.end]); + return "skip"; + }); + let out = ""; + let at = start; + for (const [from, to] of cuts) { + if (from < at) continue; + out += text.slice(at, from) + "{}"; + at = to; + } + return out + text.slice(at, node.end); +} + +/** The names a statement declares. A statement without a name (an + * expression statement, a loop) declares the names it assigns, such as the + * base of an expando `f.prop = value`; in JavaScript, where such statements + * also define exports, every name and string it spells. */ +function statementNames(statement: ts.Statement, js: boolean, hooks: UnitReuseHooks): string[] { + if (ts.isVariableStatement(statement)) { + const names: string[] = []; + for (const declaration of statement.declarationList.declarations.nodes) { + const name = declaration.name; + if (ts.isIdentifier(name)) names.push(name.text); + else + ts.walkPreorder( + name, + (child) => { + names.push((child as ts.Identifier).text); + }, + NAME_KINDS, + ); + } + return names; + } + if (ts.isExportDeclaration(statement) || ts.isExportAssignment(statement)) { + const names = new Set(); + spelledNames(statement, false, names); + return [...names].sort(); + } + const name = nameNodeOf(statement); + if (name !== undefined && (ts.isIdentifier(name) || ts.isStringLiteral(name))) return [name.text]; + if (!js) return assignedNames(statement, hooks); + const names = new Set(); + spelledNames(statement, false, names); + ts.walkPreorder(statement, (child) => { + if (ts.isStringLiteral(child)) names.add(child.text); + return undefined; + }); + return [...names].sort(); +} + +/** Whether a statement of a module changes names outside the module: + * `declare global`, a module augmentation, or `export * from`. */ +function hasGlobalEffect(statement: ts.Statement): boolean { + if (ts.isModuleDeclaration(statement)) { + const name = statement.name; + return !ts.isIdentifier(name) || name.text === "global"; + } + return ts.isExportDeclaration(statement) && statement.exportClause === undefined; +} + +/** The names a class or interface member declares: its name, or the + * identifiers a computed name spells. */ +function memberNames(member: ts.Node): string[] { + const name = nameNodeOf(member); + if (name === undefined) return ts.isConstructorDeclaration(member) ? ["constructor"] : []; + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)) + return [name.text]; + const names = new Set(); + spelledNames(name, false, names); + if (names.size === 0) names.add(name.getText()); + return [...names].sort(); +} + +/** The sites in `node` that refer to a module's namespace object: a + * namespace import or re-export, `import x = require()`, and `import()` and + * `require()` calls with a string, which `names` stand for. */ +function namespaceSites( + sf: ts.SourceFile, + statement: ts.Statement, + names: readonly string[], + hooks: UnitReuseHooks, + out: NamespaceSite[], +): void { + const add = (specifier: ts.Node, holders: readonly string[]): void => { + const exports = hooks.exportsOf(specifier); + if (exports === null) return; + out.push({ + key: `${specifier.getText(sf)}\n${holders.join(",")}`, + names: holders, + exports: digestOf(exports), + }); + }; + if (ts.isImportDeclaration(statement)) { + const bindings = statement.importClause?.namedBindings; + if (bindings !== undefined && ts.isNamespaceImport(bindings)) + add(statement.moduleSpecifier, [bindings.name.text]); + return; + } + if (ts.isImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + if (ts.isExternalModuleReference(reference)) add(reference.expression, [statement.name.text]); + return; + } + if (ts.isExportDeclaration(statement)) { + const clause = statement.exportClause; + if ( + clause !== undefined && + ts.isNamespaceExport(clause) && + statement.moduleSpecifier !== undefined + ) + add(statement.moduleSpecifier, [clause.name.getText(sf)]); + return; + } + const text = sf.text.slice(statement.pos, statement.end); + if (!text.includes("import(") && !text.includes("require(")) return; + ts.walkPreorder(statement, (child) => { + if (!ts.isCallExpression(child)) return undefined; + const callee = child.expression; + const first = child.arguments.nodes[0]; + if (first === undefined || !ts.isStringLiteralLike(first)) return undefined; + if ( + callee.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(callee) && callee.text === "require") + ) + add(first, names); + return undefined; + }); +} + +/** A module's declaration table, its declarations' nodes, and the text of + * its statements with global effect. A file that is not a module has global + * effect as a whole. */ +function fileTable( + sf: ts.SourceFile, + hooks: UnitReuseHooks, + previous: FileTable | undefined, +): { table: FileTable; nodes: DeclarationNode[]; global: string } { + // A declaration with the text it had in the previous build writes what it + // wrote then. + const before = new Map((previous?.declarations ?? []).map((d) => [d.key, d])); + const assigns = (key: string, text: string, node: ts.Node): readonly string[] => { + const then = before.get(key); + return then !== undefined && then.text === text ? then.assigns : assignedNames(node, hooks); + }; + const js = isJsSourceFileName(sf.fileName); + const declarations: Declaration[] = []; + const namespaces: NamespaceSite[] = []; + const nodes: DeclarationNode[] = []; + const keys = new Set(); + let global = ""; + const unique = (key: string): string => { + let out = key; + for (let ordinal = 1; keys.has(out); ordinal++) out = `${key}#${ordinal}`; + keys.add(out); + return out; + }; + const module = ts.isExternalModule(sf) || js; + for (const statement of sf.statements.nodes) { + if (ts.isImportDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) { + namespaceSites(sf, statement, [], hooks, namespaces); + continue; + } + if (!module || hasGlobalEffect(statement)) { + global += sf.text.slice(statement.getStart(sf), statement.end) + "\n"; + continue; + } + const names = statementNames(statement, js, hooks); + namespaceSites(sf, statement, names, hooks, namespaces); + const kind = String(statement.kind); + if (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) { + // Each member is a declaration of its own; the rest of the class or + // interface (its name, type parameters, heritage and modifiers) is the + // header, named by the class, and holds the class's facts. + const owner = names[0] ?? ""; + const seeds = ts.isInterfaceDeclaration(statement) ? owner : null; + for (const member of statement.members.nodes) { + const declared = memberNames(member); + const key = unique(`${kind}:${owner}.${declared.join(",")}`); + const text = digestOf(sf.text.slice(member.getStart(sf), member.end)); + declarations.push({ + key, + names: declared, + member: true, + owner: seeds, + text, + surface: digestOf(surfaceText(sf, member, js)), + facts: "", + assigns: assigns(key, text, member), + }); + nodes.push({ + names: declared, + member: true, + owner: seeds, + node: member, + header: false, + js, + }); + } + const members = statement.members; + const header = + sf.text.slice(statement.getStart(sf), members.pos) + + sf.text.slice(members.end, statement.end); + const digest = digestOf(header); + declarations.push({ + key: unique(`${kind}:${owner}`), + names, + member: false, + owner: null, + text: digest, + surface: digest, + facts: digestOf(hooks.facts(statement)), + assigns: [], + }); + nodes.push({ names, member: false, owner: null, node: statement, header: true, js }); + continue; + } + const digest = digestOf(sf.text.slice(statement.getStart(sf), statement.end)); + const key = unique(`${kind}:${names.join(",")}`); + declarations.push({ + key, + names, + member: false, + owner: null, + text: digest, + surface: js ? digest : digestOf(surfaceText(sf, statement, js)), + facts: digestOf(hooks.facts(statement)), + assigns: assigns(key, digest, statement), + }); + nodes.push({ names, member: false, owner: null, node: statement, header: false, js }); + } + return { + table: { + path: sf.fileName, + text: digestOf(sf.text), + entry: "", + declarations, + namespaces, + }, + nodes, + global, + }; +} + +/** The names a declaration's surface spells: a JavaScript declaration's + * whole text, a header without its members. */ +function surfaceNames(declaration: DeclarationNode): Set { + const names = new Set(); + if (!declaration.header) { + spelledNames(declaration.node, !declaration.js, names); + return names; + } + const members = new Set( + (declaration.node as ts.ClassDeclaration | ts.InterfaceDeclaration).members.nodes, + ); + ts.walkPreorder(declaration.node, (child) => { + if (members.has(child)) return "skip"; + if (ts.isIdentifier(child) || ts.isPrivateIdentifier(child)) names.add(child.text); + return undefined; + }); + return names; +} + +/** What a build knows about the units it lowers, and what it writes. */ +export class UnitReuse { + /** Whether this build records its units. A build that finds no program + * entry, the first one into a cache, records none: it lowers as a build + * without reuse does and writes only the declaration tables, so that + * recording costs the next build instead. */ + readonly records: boolean; + /** The previous build's tables by path, when its program digest matches. */ + private readonly previous: Map | null; + /** The names whose declarations changed since the previous build. */ + private readonly changed: Set; + /** The decoded file entries by path. */ + private readonly decoded = new Map | null>(); + /** The records this build made or applied, by file path, in unit order. */ + private readonly written = new Map>(); + /** Files whose units this build lowered. */ + private readonly rewritten = new Set(); + /** Counts, and the time opening (tables and changed names) and finishing + * (writing entries) took, in milliseconds. */ + readonly stats = { + candidates: 0, + hits: 0, + misses: 0, + changedNames: 0, + openMs: 0, + readMs: 0, + finishMs: 0, + /** Functions this build stored in the binary encoding, and as JSON, and + * the time making their units' entries took. */ + binary: 0, + json: 0, + storeMs: 0, + }; + + private constructor( + private readonly store: UnitStore, + private readonly program: string, + private readonly tables: Map, + previous: { program: string; tables: Map } | null, + nodes: readonly DeclarationNode[], + ) { + this.previous = previous !== null && previous.program === program ? previous.tables : null; + this.records = previous !== null; + this.changed = this.previous === null ? new Set() : changedNames(this.previous, tables, nodes); + this.stats.changedNames = this.changed.size; + } + + /** Opens reuse for a build of `files`, the program's own modules in + * module order, within `program`. */ + static open( + store: UnitStore, + program: ts.Program, + files: readonly ts.SourceFile[], + hooks: UnitReuseHooks, + ): UnitReuse { + const started = performance.now(); + const own = new Set(files); + const tables = new Map(); + const nodes: DeclarationNode[] = []; + const previous = readProgram(store); + const digest = createHash("sha256").update( + `${PROGRAM_MAGIC}\n${IR_VERSION}\n${IR_CODEC_LAYOUT}\n`, + ); + for (const sf of files) { + const file = fileTable(sf, hooks, previous?.tables.get(sf.fileName)); + tables.set(sf.fileName, file.table); + for (const node of file.nodes) nodes.push(node); + digest.update(`${sf.fileName}\n${digestOf(file.global)}\n`); + } + const others = program + .getSourceFiles() + .filter((sf) => !own.has(sf)) + .map((sf) => `${sf.fileName}\n${digestOf(sf.text)}\n`) + .sort(); + for (const other of others) digest.update(other); + const reuse = new UnitReuse(store, digest.digest("hex"), tables, previous, nodes); + reuse.stats.openMs = performance.now() - started; + return reuse; + } + + /** The stored record of unit `name`, whose text is `source` in `file` + * inside the declarations `owners`, when the unit can apply it. */ + candidate( + name: string, + file: string, + source: ts.Node | null, + owners: readonly ts.Node[], + ): StoredRecord | null { + if (this.previous === null || source === null) return null; + const before = this.previous.get(file); + const now = this.tables.get(file); + if ( + before === undefined || + now === undefined || + before.text !== now.text || + before.entry === "" + ) + return null; + let units = this.decoded.get(file); + if (units === undefined) { + const started = performance.now(); + units = readFileEntry(this.store, before.entry); + this.decoded.set(file, units); + this.stats.readMs += performance.now() - started; + } + const stored = units?.get(name); + if (stored === undefined) return null; + this.stats.candidates++; + for (const other of stored.files) { + const then = this.previous.get(other); + if (then === undefined || then.text !== this.tables.get(other)?.text) return null; + } + if (this.changed.size > 0) { + const names = new Set(); + spelledNames(source, false, names); + // A string can name a member too (`value["name"]`, `keyof` types). + ts.walkPreorder( + source, + (child) => { + names.add((child as ts.StringLiteral).text); + }, + STRING_KINDS, + ); + for (const owner of owners) { + const ownerName = nameNodeOf(owner); + if (ownerName !== undefined && ts.isIdentifier(ownerName)) names.add(ownerName.text); + } + for (const spelled of names) if (this.changed.has(spelled)) return null; + } + return storedRecord(stored); + } + + /** Notes the record a unit of `file` lowered to, with the files its + * binary function names (RecordedUnit.files), or the stored record it + * applied (`applied`). A unit without a record is noted with null. */ + note( + name: string, + file: string, + record: UnitRecord | null, + applied: boolean, + files: readonly string[] | null, + ): void { + let units = this.written.get(file); + if (units === undefined) { + units = new Map(); + this.written.set(file, units); + } + if (record !== null) { + let unit = applied ? this.decoded.get(file)?.get(name) : undefined; + if (unit === undefined) { + const started = performance.now(); + unit = storedUnit(record, files, file); + this.stats.storeMs += performance.now() - started; + if (unit.bytes !== null) this.stats.binary++; + else if (unit.record.fn !== null) this.stats.json++; + } + units.set(name, unit); + } + if (applied) this.stats.hits++; + else { + this.stats.misses++; + this.rewritten.add(file); + } + } + + /** Writes the file entries of files whose units changed and the program + * entry. A file none of whose units this build lowered or applied keeps + * no entry. */ + finish(): void { + const started = performance.now(); + for (const [path, table] of this.tables) { + const units = this.written.get(path); + if (units === undefined || units.size === 0) continue; + const before = this.previous?.get(path); + const unchanged = + before !== undefined && + before.text === table.text && + before.entry !== "" && + !this.rewritten.has(path) && + this.decoded.get(path)?.size === units.size; + table.entry = unchanged ? before.entry : this.store.writeFile(encodeFileEntry(units)); + } + this.store.writeProgram(encodeProgramEntry(this.program, this.tables)); + this.stats.finishMs = performance.now() - started; + } +} + +/** The changed names of a build (see the comment at the top) from the + * previous build's tables and this build's. */ +function changedNames( + before: Map, + now: Map, + nodes: readonly DeclarationNode[], +): Set { + const surfaces = new Set(); + const direct = new Set(); + // Text reaches a member through a value whose type names its owner, so a + // member with another type stops the units that spell it, and an + // interface's member gives the interface another type. A class's own type + // changes with its header's facts. + const typeChanged = (declaration: { + readonly names: readonly string[]; + readonly member: boolean; + readonly owner: string | null; + }): void => { + for (const name of declaration.names) (declaration.member ? direct : surfaces).add(name); + if (declaration.owner !== null) surfaces.add(declaration.owner); + }; + const compare = (from: FileTable | undefined, to: FileTable | undefined): void => { + const old = new Map((from?.declarations ?? []).map((d) => [d.key, d])); + const seen = new Set(); + for (const declaration of to?.declarations ?? []) { + seen.add(declaration.key); + const previous = old.get(declaration.key); + if ( + previous === undefined || + previous.surface !== declaration.surface || + previous.facts !== declaration.facts + ) + typeChanged(declaration); + else if (previous.text !== declaration.text) + for (const name of declaration.names) direct.add(name); + else continue; + for (const name of declaration.assigns) direct.add(name); + for (const name of previous?.assigns ?? []) direct.add(name); + } + for (const declaration of from?.declarations ?? []) + if (!seen.has(declaration.key)) { + typeChanged(declaration); + for (const name of declaration.assigns) direct.add(name); + } + const sites = new Map((from?.namespaces ?? []).map((site) => [site.key, site.exports])); + for (const site of to?.namespaces ?? []) + if (sites.get(site.key) !== site.exports) for (const name of site.names) surfaces.add(name); + }; + for (const [path, table] of now) compare(before.get(path), table); + for (const [path, table] of before) if (!now.has(path)) compare(table, undefined); + if (surfaces.size > 0) { + // A declaration whose surface spells a changed name can have another + // type now (typeChanged); repeat until no surface adds a name. + const pending = nodes.map((declaration) => ({ + declaration, + spells: surfaceNames(declaration), + })); + for (let grew = true; grew;) { + grew = false; + for (let index = 0; index < pending.length; index++) { + const entry = pending[index]!; + let hit = false; + for (const name of entry.spells) + if (surfaces.has(name)) { + hit = true; + break; + } + if (!hit) continue; + typeChanged(entry.declaration); + // Each declaration joins once. + pending[index] = pending[pending.length - 1]!; + pending.pop(); + index--; + grew = true; + } + } + } + for (const name of direct) surfaces.add(name); + return surfaces; +} + +function encodeProgramEntry(program: string, tables: Map): Uint8Array { + const lines = [PROGRAM_MAGIC, program]; + for (const table of tables.values()) lines.push(JSON.stringify(table)); + return Buffer.from(lines.join("\n"), "utf8"); +} + +function readProgram(store: UnitStore): { program: string; tables: Map } | null { + const bytes = store.readProgram(); + if (bytes === null) return null; + try { + const lines = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) + .toString("utf8") + .split("\n"); + if (lines[0] !== PROGRAM_MAGIC || lines.length < 2) return null; + const tables = new Map(); + for (const line of lines.slice(2)) { + const table = JSON.parse(line) as FileTable; + tables.set(table.path, table); + } + return { program: lines[1]!, tables }; + } catch { + // An unreadable entry is a miss. + return null; + } +} + +/** A file entry: per stored unit, the unit without its function's bytes as + * JSON, then the bytes when it has them. */ +function encodeFileEntry(units: Map): Uint8Array { + const w = new IrWriter(); + w.str(FILE_MAGIC); + w.length(units.size); + for (const unit of units.values()) { + w.str(JSON.stringify({ record: unit.record, files: unit.files })); + if (unit.bytes === null) w.byte(0); + else { + w.byte(1); + w.blob(unit.bytes); + } + } + return w.finish(); +} + +function readFileEntry(store: UnitStore, name: string): Map | null { + const bytes = store.readFile(name); + if (bytes === null) return null; + try { + const r = new IrReader(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)); + if (r.str() !== FILE_MAGIC) return null; + const units = new Map(); + for (let count = r.length(); count > 0; count--) { + const unit = JSON.parse(r.str()) as { record: UnitRecord; files: string[] }; + const fn = r.byte() === 0 ? null : r.blob(); + units.set(unit.record.name, { record: unit.record, files: unit.files, bytes: fn }); + } + return r.done() ? units : null; + } catch { + return null; + } +} + +/** Throws when a unit lowered to another record than its stored one + * (SCRIPTC_INCR_VERIFY=1), naming the unit and the first part that + * differs. `pending` is the stored record's entries the tables did not hold + * at the unit's turn. */ +export function verifyStoredUnit( + name: string, + storedRecord: StoredRecord, + pending: Readonly>, + lowered: UnitRecord | null, + reasons: readonly string[], +): void { + const stored = storedRecord.record; + const fail = (what: string): never => { + throw new InternalCompilerError( + `SCRIPTC_INCR_VERIFY: unit ${name} lowered to another ${what} than its stored record`, + ); + }; + if (lowered === null) return fail(`result (no record: ${reasons.join(", ")})`); + if (lowered.poisoned !== stored.poisoned) fail("poison"); + // As JSON text, which keeps key order, whichever encoding each record has. + const text = (fn: IrFunction | null): string | null => + fn === null ? null : (encodeUnitFunction(fn) ?? ""); + const loweredFn = lowered.fn === null ? null : decodeRecordFunction(lowered.fn); + if (text(loweredFn) !== text(storedRecord.fn())) fail("function"); + if (JSON.stringify(lowered.events) !== JSON.stringify(stored.events)) fail("event list"); + // A stored entry the tables hold already is one an earlier unit of this + // build added: applying the record leaves it as it is. + if (JSON.stringify(lowered.tables) !== JSON.stringify(pending)) fail("table entries"); +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 02539b610..0fd084979 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1,6 +1,6 @@ import { InternalCompilerError } from "../errors.js"; import { keyedHelperName, type KeyedNameOwner } from "./lowering/stable-names.js"; -import type { HeldContents, JournalTable } from "./lowering/unit-journal.js"; +import { contentDigest, type HeldContents, type JournalTable } from "./lowering/unit-journal.js"; import { mockCallRecordType, mockContextType } from "./mock-shapes.js"; import * as ts from "./ts7/adapter.js"; import { bodyReadsArguments } from "./arguments-usage.js"; @@ -716,9 +716,39 @@ export class UnionRegistry { * the key its arms give. A unit that adds a recursive union changes * journalSizes, so the unions past a unit's start are interned ones. */ journalTable(): JournalTable { + // The position of each union by id, for the unions indexed so far. + let indexes = new Map(); + let indexed = 0; + const indexOf = (id: string): number | undefined => { + if (indexed > this.unions.length) { + indexes = new Map(); + indexed = 0; + } + for (; indexed < this.unions.length; indexed++) + indexes.set(this.unions[indexed]!.id, indexed); + const index = indexes.get(id); + return index !== undefined && this.unions[index]?.id === id ? index : undefined; + }; return { name: "unions", size: () => this.unions.length, + digest: (key, before) => { + if (!this.byId.has(key)) return null; + let index = indexOf(key); + if (index === undefined) { + // A list that was cut and grown again holds other unions at the + // positions indexed before. + indexes = new Map(); + indexed = 0; + index = indexOf(key); + } + if (index === undefined || index >= before) return null; + return contentDigest(JSON.stringify(this.unions[index]!)); + }, + holds: (entry) => { + const def = JSON.parse(entry) as IrUnionDef; + return this.byKey.has(unionKey(def.arms, def.discriminant)); + }, entries: (from) => { const out: string[] = []; for (const def of this.unions.slice(from)) { @@ -729,6 +759,12 @@ export class UnionRegistry { } return out; }, + agrees: (entries) => + entries.every((entry) => { + const def = JSON.parse(entry) as IrUnionDef; + const held = this.byKey.get(unionKey(def.arms, def.discriminant)); + return held === undefined ? !this.byId.has(def.id) : held === def.id; + }), apply: (entries) => { for (const entry of entries) { const def = JSON.parse(entry) as IrUnionDef; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index b837b74fd..4ed209262 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -23,6 +23,11 @@ import { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js"; import { setLibDiagnosticsStore } from "./frontend/ts7/lib-diagnostics.js"; import { analyzeWithVerdictCache, fileVerdictStore } from "./coverage/verdict-cache.js"; import { coverageCompilerIdentity } from "./coverage/verdict-startup.js"; +import { + directoryUnitStore, + unitReuseEnabled, + type UnitStore, +} from "./frontend/lowering/unit-reuse.js"; import { commentOnlyReplay } from "./coverage/verdict-semantic.js"; import { llvmRefusalDiag, targetRefusalDiag } from "./backend/target-diagnostics.js"; import type { @@ -914,6 +919,37 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD ); } +/** The unit store of a Node-hosted build (unit-reuse.ts), keyed by the + * compiler, the entry, the options that reach lowering and the target + * platform. */ +async function nodeUnitStore( + entryPath: string, + opts: CompileRequestOptions, + ffi: FfiProfile | null, + buildPlatform: string, +): Promise { + if (!unitReuseEnabled() || provenanceSources() !== null) return null; + const root = await prepareBuildCacheRoot(buildCacheRoot()); + if (root === null) return null; + const directory = join(root, "units-v1"); + const implementation = await coverageCompilerIdentity(directory); + if (implementation === null) return null; + const { outPath: _outPath, outDir: _outDir, ...lowering } = opts; + const key = createHash("sha256") + .update( + JSON.stringify({ + schema: 1, + entry: resolve(entryPath), + options: lowering, + ffi, + platform: buildPlatform, + compiler: implementation.digest, + }), + ) + .digest("hex"); + return directoryUnitStore(directory, key); +} + interface PreparedExecutable { llvmSource: LlvmInput; /** The kept LLVM text beside the executable, installed when the build ends. */ @@ -945,6 +981,9 @@ async function prepareExecutableInput( buildPlatform, nodeFrontend, timing, + new Map(), + true, + await nodeUnitStore(entryPath, opts, ffi, buildPlatform), ); if (!prepared.ok) return prepared; const { mod, sourceTexts } = prepared; diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts new file mode 100644 index 000000000..40d5fe3d2 --- /dev/null +++ b/packages/compiler/src/ir/codec.generated.ts @@ -0,0 +1,5099 @@ +// Generated from packages/compiler/src/ir/ir.ts by scripts/generate-ir-codec.mjs. +// Run `pnpm generate:ir-codec` after changing the IR's types. +import type { IrBindingSource, IrClassDef, IrContracts, IrExpr, IrFfiCallbackParam, IrFfiContextParam, IrFfiImport, IrFfiParam, IrFfiReleaseParam, IrFunction, IrGlobal, IrLibCallback, IrLibExport, IrLibIdentity, IrLibSection, IrLibTrapOverlay, IrLocal, IrModule, IrNodeIslandModules, IrParam, IrRecordShape, IrStmt, IrType, IrUnionDef, IrUnionDiscriminant, IrWasmModule, IrWasmUntranslated, SrcLoc } from "./ir.js"; +import type { IrReader, IrWriter } from "./codec.js"; + +/** A digest of the coders below: an encoding written with other coders is + * refused instead of misread, also after a change that keeps IR_VERSION. */ +export const IR_CODEC_LAYOUT = "978da33223a8447d"; + +/** Writes `mod` field by field (codec.ts describes the bytes). */ +export function encodeIrModule(w: IrWriter, mod: IrModule): void { + encIrModule(w, mod); +} +/** Reads a module encodeIrModule wrote. */ +export function decodeIrModule(r: IrReader): IrModule { + return decIrModule(r); +} +/** Writes one function. */ +export function encodeIrFunction(w: IrWriter, fn: IrFunction): void { + encIrFunction(w, fn); +} +/** Reads a function encodeIrFunction wrote. */ +export function decodeIrFunction(r: IrReader): IrFunction { + return decIrFunction(r); +} + +const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"]; +const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; +const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"]; +const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"]; +const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"]; +const KEYS5: readonly string[] = ["kind","elem","optionalElements"]; +const KEYS6: readonly string[] = ["kind"]; +const KEYS7: readonly string[] = ["kind"]; +const KEYS8: readonly string[] = ["kind","elem"]; +const KEYS9: readonly string[] = ["kind"]; +const KEYS10: readonly string[] = ["kind"]; +const KEYS11: readonly string[] = ["kind"]; +const KEYS12: readonly string[] = ["kind"]; +const KEYS13: readonly string[] = ["kind","className"]; +const KEYS14: readonly string[] = ["kind"]; +const KEYS15: readonly string[] = ["kind"]; +const KEYS16: readonly string[] = ["kind"]; +const KEYS17: readonly string[] = ["kind"]; +const KEYS18: readonly string[] = ["kind"]; +const KEYS19: readonly string[] = ["kind"]; +const KEYS20: readonly string[] = ["kind"]; +const KEYS21: readonly string[] = ["kind"]; +const KEYS22: readonly string[] = ["kind"]; +const KEYS23: readonly string[] = ["kind","params","ret","rest","restAbi","argumentsAll"]; +const KEYS24: readonly string[] = ["kind","async","yieldT","retT","nextT"]; +const KEYS25: readonly string[] = ["kind"]; +const KEYS26: readonly string[] = ["kind"]; +const KEYS27: readonly string[] = ["kind"]; +const KEYS28: readonly string[] = ["kind"]; +const KEYS29: readonly string[] = ["kind"]; +const KEYS30: readonly string[] = ["kind"]; +const KEYS31: readonly string[] = ["kind","key","value"]; +const KEYS32: readonly string[] = ["kind","moduleId"]; +const KEYS33: readonly string[] = ["kind"]; +const KEYS34: readonly string[] = ["kind"]; +const KEYS35: readonly string[] = ["kind"]; +const KEYS36: readonly string[] = ["kind","className"]; +const KEYS37: readonly string[] = ["kind"]; +const KEYS38: readonly string[] = ["kind","inner"]; +const KEYS39: readonly string[] = ["kind","shapeId"]; +const KEYS40: readonly string[] = ["kind"]; +const KEYS41: readonly string[] = ["kind"]; +const KEYS42: readonly string[] = ["kind"]; +const KEYS43: readonly string[] = ["kind","elem"]; +const KEYS44: readonly string[] = ["kind"]; +const KEYS45: readonly string[] = ["kind"]; +const KEYS46: readonly string[] = ["kind"]; +const KEYS47: readonly string[] = ["kind"]; +const KEYS48: readonly string[] = ["kind"]; +const KEYS49: readonly string[] = ["kind"]; +const KEYS50: readonly string[] = ["kind","unionId"]; +const KEYS51: readonly string[] = ["kind"]; +const KEYS52: readonly string[] = ["kind"]; +const KEYS53: readonly string[] = ["file","start","end"]; +const KEYS54: readonly string[] = ["yieldT","nextT","resultType","closesSource","tag","inheritsTag"]; +const KEYS55: readonly string[] = ["name","localCaptures","localBaseCapture","baseValueGlobal","jsName","jsLength","ownStaticFields","prototypeDataHelper","instancePrototypeHelper","stringTagHelper","tracksOwnFields","errorNameAfter","errorBagOrder","symbolFields","runtime","arrayElem","arrayFields","arraySpecies","base","fields","methods","prototypeNames","abstract","abstractMethods","genericOf","loc"]; +const KEYS56: readonly string[] = ["id","name","type","mutable","tdz","initFlag","source"]; +const KEYS57: readonly string[] = ["id","fields","tuple","indexValue","freezable","declaredOrder","accessorOrder","orderVariants"]; +const KEYS58: readonly string[] = ["id","arms","discriminant"]; +const KEYS59: readonly string[] = ["file","pkg"]; +const KEYS60: readonly string[] = ["root","files","links","location"]; +const KEYS61: readonly string[] = ["name","symbol","library","callbackOperation","callbackTarget","params","returns"]; +const KEYS62: readonly string[] = ["buildIdSymbol","abiVersionSymbol","buildId","abiVersion"]; +const KEYS63: readonly string[] = ["sha256","bytes"]; +const KEYS64: readonly string[] = ["sha256","size","reason"]; +const KEYS65: readonly string[] = ["localId","name","type","owned"]; +const KEYS66: readonly string[] = ["id","name","type","mutable","source","boxed","tdz"]; +const KEYS67: readonly string[] = ["localId","name","type","owned","slot"]; +const KEYS68: readonly string[] = ["kind","arr","index","loc"]; +const KEYS69: readonly string[] = ["kind","arr","index","value","loc"]; +const KEYS70: readonly string[] = ["kind","arr","length","loc","pop","reserve"]; +const KEYS71: readonly string[] = ["kind","arr","index","loc"]; +const KEYS72: readonly string[] = ["kind","localId","value","initializes","loc"]; +const KEYS73: readonly string[] = ["kind","labels","body","loc"]; +const KEYS74: readonly string[] = ["kind","label","loc"]; +const KEYS75: readonly string[] = ["kind","arr","index","value","loc"]; +const KEYS76: readonly string[] = ["kind","label","loc"]; +const KEYS77: readonly string[] = ["kind","body","cond","labels","loc"]; +const KEYS78: readonly string[] = ["kind","expr","loc"]; +const KEYS79: readonly string[] = ["kind","obj","className","field","value","sloppy","define","attributesChecked","loc"]; +const KEYS80: readonly string[] = ["kind","init","cond","update","body","labels","loc"]; +const KEYS81: readonly string[] = ["kind","localId","iterable","body","labels","loc"]; +const KEYS82: readonly string[] = ["kind","cond","then","else_","loc"]; +const KEYS83: readonly string[] = ["kind","obj","shapeId","key","loc"]; +const KEYS84: readonly string[] = ["kind","obj","shapeId","key","value","overflowOnly","loc"]; +const KEYS85: readonly string[] = ["kind","obj","shapeId","field","value","loc"]; +const KEYS86: readonly string[] = ["kind","localId","loc"]; +const KEYS87: readonly string[] = ["kind","value","awaited","loc"]; +const KEYS88: readonly string[] = ["kind","code","message","loc"]; +const KEYS89: readonly string[] = ["kind","disc","cases","labels","loc"]; +const KEYS90: readonly string[] = ["kind","value","loc"]; +const KEYS91: readonly string[] = ["kind","tryBody","catchBody","catchLocalId","dropFinallyErrors","finallyBody","suppressFinallyErrors","loc"]; +const KEYS92: readonly string[] = ["kind","localId","init","loc"]; +const KEYS93: readonly string[] = ["kind","cond","body","labels","loc"]; +const KEYS94: readonly string[] = ["kind","shapeId"]; +const KEYS95: readonly string[] = ["loc","scope"]; +const KEYS96: readonly string[] = ["field","cases"]; +const KEYS97: readonly string[] = ["name","slot","params","returns","unregisteredTrap"]; +const KEYS98: readonly string[] = ["symbol","fnName","params","returns","inboundBytesTrap","inboundIntTrap","inboundCstringTrap"]; +const KEYS99: readonly string[] = ["code","teaching","remediation"]; +const KEYS100: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS101: readonly string[] = ["kind","arr","index","type","loc"]; +const KEYS102: readonly string[] = ["kind","arr","index","type","loc"]; +const KEYS103: readonly string[] = ["kind","elems","spreads","capacity","type","loc"]; +const KEYS104: readonly string[] = ["kind","length","type","loc"]; +const KEYS105: readonly string[] = ["kind","arr","index","type","loc"]; +const KEYS106: readonly string[] = ["kind","localId","value","type","loc"]; +const KEYS107: readonly string[] = ["kind","value","type","adopt","loc"]; +const KEYS108: readonly string[] = ["kind","value","promiseTag","type","loc"]; +const KEYS109: readonly string[] = ["kind","op","left","right","type","loc"]; +const KEYS110: readonly string[] = ["kind","value","type","loc"]; +const KEYS111: readonly string[] = ["kind","method","receiver","args","type","loc","invalidNaN"]; +const KEYS112: readonly string[] = ["kind","source","from","type","loc"]; +const KEYS113: readonly string[] = ["kind","callee","args","type","loc"]; +const KEYS114: readonly string[] = ["kind","callee","receiver","args","type","loc"]; +const KEYS115: readonly string[] = ["kind","value","className","type","loc"]; +const KEYS116: readonly string[] = ["kind","value","type","loc"]; +const KEYS117: readonly string[] = ["kind","value","test","className","negated","type","loc"]; +const KEYS118: readonly string[] = ["kind","value","type","loc"]; +const KEYS119: readonly string[] = ["kind","id","type","loc"]; +const KEYS120: readonly string[] = ["kind","className","type","loc","captures"]; +const KEYS121: readonly string[] = ["kind","fnName","captures","adapts","type","loc"]; +const KEYS122: readonly string[] = ["kind","value","type","loc"]; +const KEYS123: readonly string[] = ["kind","elems","type","loc"]; +const KEYS124: readonly string[] = ["kind","callee","receiver","calleeName","calleeNameValue","args","spreads","type","loc"]; +const KEYS125: readonly string[] = ["kind","value","preserveRefs","adopt","exact","inferredSite","origin","type","loc"]; +const KEYS126: readonly string[] = ["kind","value","spelling","firstProp","type","loc"]; +const KEYS127: readonly string[] = ["kind","value","type","loc","liveRef","fnName","fnLength"]; +const KEYS128: readonly string[] = ["kind","key","negated","value","type","loc"]; +const KEYS129: readonly string[] = ["kind","recv","method","calleeName","calleeNameValue","args","type","loc"]; +const KEYS130: readonly string[] = ["kind","value","count","notIterableMessage","type","loc"]; +const KEYS131: readonly string[] = ["kind","key","optional","value","type","loc"]; +const KEYS132: readonly string[] = ["kind","fields","type","loc"]; +const KEYS133: readonly string[] = ["kind","left","right","negated","type","loc"]; +const KEYS134: readonly string[] = ["kind","test","bytesElem","negated","value","type","loc"]; +const KEYS135: readonly string[] = ["kind","import","args","type","loc"]; +const KEYS136: readonly string[] = ["kind","unionId","type","loc"]; +const KEYS137: readonly string[] = ["kind","obj","className","field","type","loc"]; +const KEYS138: readonly string[] = ["kind","op","prefix","obj","className","field","fieldDyn","type","loc"]; +const KEYS139: readonly string[] = ["kind","mode","gen","arg","type","loc"]; +const KEYS140: readonly string[] = ["kind","op","prefix","localId","type","loc"]; +const KEYS141: readonly string[] = ["kind","value","className","type","loc"]; +const KEYS142: readonly string[] = ["kind","value","classValue","type","loc"]; +const KEYS143: readonly string[] = ["kind","name","args","type","loc"]; +const KEYS144: readonly string[] = ["kind","value","indent","circular","type","loc"]; +const KEYS145: readonly string[] = ["kind","fn","args","type","loc","prototypeIdentityOnly","realTimer","assertOptions"]; +const KEYS146: readonly string[] = ["kind","op","left","right","type","loc"]; +const KEYS147: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS148: readonly string[] = ["kind","seed","type","loc"]; +const KEYS149: readonly string[] = ["kind","moduleId","type","loc"]; +const KEYS150: readonly string[] = ["kind","className","args","type","loc"]; +const KEYS151: readonly string[] = ["kind","executor","type","loc"]; +const KEYS152: readonly string[] = ["kind","callee","args","type","loc"]; +const KEYS153: readonly string[] = ["kind","left","right","type","loc"]; +const KEYS154: readonly string[] = ["kind","value","spelling","type","loc"]; +const KEYS155: readonly string[] = ["kind","id","receiver","body","type","loc"]; +const KEYS156: readonly string[] = ["kind","left","right","retag","type","loc"]; +const KEYS157: readonly string[] = ["kind","value","type","loc"]; +const KEYS158: readonly string[] = ["kind","type","loc"]; +const KEYS159: readonly string[] = ["kind","source","overrides","type","loc"]; +const KEYS160: readonly string[] = ["kind","obj","shapeId","field","type","loc"]; +const KEYS161: readonly string[] = ["kind","obj","shapeId","field","type","loc"]; +const KEYS162: readonly string[] = ["kind","obj","shapeId","key","overflowOnly","missNaN","type","loc"]; +const KEYS163: readonly string[] = ["kind","fields","nullProto","type","loc"]; +const KEYS164: readonly string[] = ["kind","obj","shapeId","type","loc"]; +const KEYS165: readonly string[] = ["kind","obj","shapeId","key","type","loc"]; +const KEYS166: readonly string[] = ["kind","obj","shapeId","type","loc"]; +const KEYS167: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS168: readonly string[] = ["kind","pattern","flags","type","loc"]; +const KEYS169: readonly string[] = ["kind","type","loc"]; +const KEYS170: readonly string[] = ["kind","stmts","result","generatorDelegate","diverges","type","loc"]; +const KEYS171: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS172: readonly string[] = ["kind","seed","type","loc"]; +const KEYS173: readonly string[] = ["kind","op","left","right","type","loc"]; +const KEYS174: readonly string[] = ["kind","left","right","type","loc"]; +const KEYS175: readonly string[] = ["kind","negated","left","right","type","loc"]; +const KEYS176: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS177: readonly string[] = ["kind","value","type","loc"]; +const KEYS178: readonly string[] = ["kind","key","cooked","type","loc"]; +const KEYS179: readonly string[] = ["kind","cond","then","else_","type","loc"]; +const KEYS180: readonly string[] = ["kind","operand","type","loc"]; +const KEYS181: readonly string[] = ["kind","operand","type","loc"]; +const KEYS182: readonly string[] = ["kind","op","operand","type","loc"]; +const KEYS183: readonly string[] = ["kind","unionId","field","value","type","loc"]; +const KEYS184: readonly string[] = ["kind","unionId","negated","sameValue","left","right","type","loc"]; +const KEYS185: readonly string[] = ["kind","unionId","tag","union","func","negated","type","loc"]; +const KEYS186: readonly string[] = ["kind","unionId","tag","negated","value","type","loc"]; +const KEYS187: readonly string[] = ["kind","unionId","key","value","type","loc"]; +const KEYS188: readonly string[] = ["kind","unionId","tag","value","type","loc"]; +const KEYS189: readonly string[] = ["kind","fromId","unionId","tags","value","type","loc"]; +const KEYS190: readonly string[] = ["kind","unionId","tag","value","type","loc"]; +const KEYS191: readonly string[] = ["kind","unit","type","loc"]; +const KEYS192: readonly string[] = ["kind","value","type","loc"]; +const KEYS193: readonly string[] = ["kind","localId","type","loc"]; +const KEYS194: readonly string[] = ["kind","className","method","args","type","loc","targets"]; +const KEYS195: readonly string[] = ["kind","value","awaited","delegated","captureCompletion","type","loc"]; +const KEYS196: readonly string[] = ["name","type"]; +const KEYS197: readonly string[] = ["name","type"]; +const KEYS198: readonly string[] = ["path","size","sha256","placeholder"]; +const KEYS199: readonly string[] = ["path","target"]; +const KEYS200: readonly string[] = ["diff","skipPrototype"]; +const KEYS201: readonly string[] = ["returnType"]; +const KEYS202: readonly string[] = ["test","body"]; +const KEYS203: readonly string[] = ["field","globalId"]; +const KEYS204: readonly string[] = ["field","wellKnown"]; +const KEYS205: readonly string[] = ["tag","values"]; +const KEYS206: readonly string[] = ["context"]; +const KEYS207: readonly string[] = ["callback"]; +const KEYS208: readonly string[] = ["callback"]; +const KEYS209: readonly string[] = ["arg","what"]; +const KEYS210: readonly string[] = ["key","value"]; +const KEYS211: readonly string[] = ["key","value"]; +const KEYS212: readonly string[] = ["name","value"]; +const KEYS213: readonly string[] = ["name","value","overflow"]; +const KEYS214: readonly string[] = ["id","params","returns","lifetime","invoke"]; +const KEYS215: readonly string[] = ["release","params","returns"]; + +function encIrModule(w: IrWriter, v: IrModule): void { + w.keys(Object.keys(v), KEYS0); + w.num(v.irVersion); + w.str(v.sourceFile); + encT2(w, v.functions); + if (v.classes === undefined) w.byte(0); + else { + w.byte(1); + encT3(w, v.classes); + } + if (v.globals === undefined) w.byte(0); + else { + w.byte(1); + encT4(w, v.globals); + } + if (v.records === undefined) w.byte(0); + else { + w.byte(1); + encT5(w, v.records); + } + if (v.unions === undefined) w.byte(0); + else { + w.byte(1); + encT6(w, v.unions); + } + w.str(v.entry); + if (v.workers === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.workers); + } + if (v.declBoundary === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.declBoundary); + } + if (v.inferredFactFiles === undefined) w.byte(0); + else { + w.byte(1); + encT7(w, v.inferredFactFiles); + } + if (v.declOrigins === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.declOrigins); + } + if (v.lifetimeRegions === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.lifetimeRegions); + } + if (v.foreignElementKeys === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.foreignElementKeys); + } + if (v.threadSlotKeys === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.threadSlotKeys); + } + if (v.freezableArrays === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.freezableArrays); + } + if (v.sloppyDynWrites === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.sloppyDynWrites); + } + if (v.untypedIntegrity === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.untypedIntegrity); + } + if (v.commonJsEntry === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.commonJsEntry); + } + if (v.nodeIsland === undefined) w.byte(0); + else { + w.byte(1); + encT9(w, v.nodeIsland); + } + if (v.ffiImports === undefined) w.byte(0); + else { + w.byte(1); + encT10(w, v.ffiImports); + } + if (v.lib === undefined) w.byte(0); + else { + w.byte(1); + encIrLibSection(w, v.lib); + } + if (v.contracts === undefined) w.byte(0); + else { + w.byte(1); + encIrContracts(w, v.contracts); + } + if (v.int32ArrayCandidates === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.int32ArrayCandidates); + } + if (v.wasmModules === undefined) w.byte(0); + else { + w.byte(1); + encT13(w, v.wasmModules); + } + if (v.wasmUntranslated === undefined) w.byte(0); + else { + w.byte(1); + encT14(w, v.wasmUntranslated); + } + if (v.wasmCustomSections === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.wasmCustomSections); + } +} +function decIrModule(r: IrReader): IrModule { + const f0 = (r.num() as NonNullable); + const f1 = r.str(); + const f2 = decT2(r); + const f3 = r.tag(2, "IrModule.classes") === 0 ? undefined : decT3(r); + const f4 = r.tag(2, "IrModule.globals") === 0 ? undefined : decT4(r); + const f5 = r.tag(2, "IrModule.records") === 0 ? undefined : decT5(r); + const f6 = r.tag(2, "IrModule.unions") === 0 ? undefined : decT6(r); + const f7 = r.str(); + const f8 = r.tag(2, "IrModule.workers") === 0 ? undefined : r.bool(); + const f9 = r.tag(2, "IrModule.declBoundary") === 0 ? undefined : (r.bool() as NonNullable); + const f10 = r.tag(2, "IrModule.inferredFactFiles") === 0 ? undefined : decT7(r); + const f11 = r.tag(2, "IrModule.declOrigins") === 0 ? undefined : decT8(r); + const f12 = r.tag(2, "IrModule.lifetimeRegions") === 0 ? undefined : (r.bool() as NonNullable); + const f13 = r.tag(2, "IrModule.foreignElementKeys") === 0 ? undefined : decT8(r); + const f14 = r.tag(2, "IrModule.threadSlotKeys") === 0 ? undefined : decT8(r); + const f15 = r.tag(2, "IrModule.freezableArrays") === 0 ? undefined : decT8(r); + const f16 = r.tag(2, "IrModule.sloppyDynWrites") === 0 ? undefined : decT8(r); + const f17 = r.tag(2, "IrModule.untypedIntegrity") === 0 ? undefined : (r.bool() as NonNullable); + const f18 = r.tag(2, "IrModule.commonJsEntry") === 0 ? undefined : r.bool(); + const f19 = r.tag(2, "IrModule.nodeIsland") === 0 ? undefined : decT9(r); + const f20 = r.tag(2, "IrModule.ffiImports") === 0 ? undefined : decT10(r); + const f21 = r.tag(2, "IrModule.lib") === 0 ? undefined : decIrLibSection(r); + const f22 = r.tag(2, "IrModule.contracts") === 0 ? undefined : decIrContracts(r); + const f23 = r.tag(2, "IrModule.int32ArrayCandidates") === 0 ? undefined : decT8(r); + const f24 = r.tag(2, "IrModule.wasmModules") === 0 ? undefined : decT13(r); + const f25 = r.tag(2, "IrModule.wasmUntranslated") === 0 ? undefined : decT14(r); + const f26 = r.tag(2, "IrModule.wasmCustomSections") === 0 ? undefined : r.bool(); + const o: IrModule = { irVersion: f0, sourceFile: f1, functions: f2, ...(f3 !== undefined ? { classes: f3 } : {}), ...(f4 !== undefined ? { globals: f4 } : {}), ...(f5 !== undefined ? { records: f5 } : {}), ...(f6 !== undefined ? { unions: f6 } : {}), entry: f7, ...(f8 !== undefined ? { workers: f8 } : {}), ...(f9 !== undefined ? { declBoundary: f9 } : {}), ...(f10 !== undefined ? { inferredFactFiles: f10 } : {}), ...(f11 !== undefined ? { declOrigins: f11 } : {}), ...(f12 !== undefined ? { lifetimeRegions: f12 } : {}), ...(f13 !== undefined ? { foreignElementKeys: f13 } : {}), ...(f14 !== undefined ? { threadSlotKeys: f14 } : {}), ...(f15 !== undefined ? { freezableArrays: f15 } : {}), ...(f16 !== undefined ? { sloppyDynWrites: f16 } : {}), ...(f17 !== undefined ? { untypedIntegrity: f17 } : {}), ...(f18 !== undefined ? { commonJsEntry: f18 } : {}), ...(f19 !== undefined ? { nodeIsland: f19 } : {}), ...(f20 !== undefined ? { ffiImports: f20 } : {}), ...(f21 !== undefined ? { lib: f21 } : {}), ...(f22 !== undefined ? { contracts: f22 } : {}), ...(f23 !== undefined ? { int32ArrayCandidates: f23 } : {}), ...(f24 !== undefined ? { wasmModules: f24 } : {}), ...(f25 !== undefined ? { wasmUntranslated: f25 } : {}), ...(f26 !== undefined ? { wasmCustomSections: f26 } : {}) }; + return o; +} + +function encIrFunction(w: IrWriter, v: IrFunction): void { + w.keys(Object.keys(v), KEYS1); + w.str(v.name); + if (v.sourceName === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.sourceName); + } + if (v.ownsPrototype === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.ownsPrototype); + } + encT15(w, v.params); + encIrType(w, v.returnType); + encT17(w, v.locals); + if (v.captures === undefined) w.byte(0); + else { + w.byte(1); + encT15(w, v.captures); + } + if (v.classCaptures === undefined) w.byte(0); + else { + w.byte(1); + encT18(w, v.classCaptures); + } + encT19(w, v.body); + encSrcLoc(w, v.loc); + if (v.tier === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.tier); + } + if (v.speculativeDispatch === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.speculativeDispatch); + } + if (v.fastRead === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.fastRead); + } + if (v.async === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.async); + } + if (v.generator === undefined) w.byte(0); + else { + w.byte(1); + encT21(w, v.generator); + } + if (v.asyncCacheGlobal === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.asyncCacheGlobal); + } + if (v.asyncCycleCacheGlobal === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.asyncCycleCacheGlobal); + } +} +function decIrFunction(r: IrReader): IrFunction { + const f0 = r.str(); + const f1 = r.tag(2, "IrFunction.sourceName") === 0 ? undefined : r.str(); + const f2 = r.tag(2, "IrFunction.ownsPrototype") === 0 ? undefined : (r.bool() as NonNullable); + const f3 = decT15(r); + const f4 = decIrType(r); + const f5 = decT17(r); + const f6 = r.tag(2, "IrFunction.captures") === 0 ? undefined : decT15(r); + const f7 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r); + const f8 = decT19(r); + const f9 = decSrcLoc(r); + const f10 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); + const f11 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); + const f12 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); + const f13 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); + const f14 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); + const f15 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); + const f16 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); + const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { async: f13 } : {}), ...(f14 !== undefined ? { generator: f14 } : {}), ...(f15 !== undefined ? { asyncCacheGlobal: f15 } : {}), ...(f16 !== undefined ? { asyncCycleCacheGlobal: f16 } : {}) }; + return o; +} + +function encT2(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrFunction(w, item); + } +} +function decT2(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrFunction(r)); + return out; +} + +function encT3(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrClassDef(w, item); + } +} +function decT3(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrClassDef(r)); + return out; +} + +function encT4(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrGlobal(w, item); + } +} +function decT4(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrGlobal(r)); + return out; +} + +function encT5(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrRecordShape(w, item); + } +} +function decT5(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrRecordShape(r)); + return out; +} + +function encT6(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrUnionDef(w, item); + } +} +function decT6(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrUnionDef(r)); + return out; +} + +function encT7(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT26(w, item); + } +} +function decT7(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT26(r)); + return out; +} + +function encT8(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + w.str(item); + } +} +function decT8(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(r.str()); + return out; +} + +function encT9(w: IrWriter, v: NonNullable): void { + w.keys(Object.keys(v), KEYS2); + w.str(v.libnode); + w.str(v.nodeVersion); + if (v.modules === undefined) w.byte(0); + else { + w.byte(1); + encIrNodeIslandModules(w, v.modules); + } +} +function decT9(r: IrReader): NonNullable { + const f0 = r.str(); + const f1 = r.str(); + const f2 = r.tag(2, "IrModule.nodeIsland.modules") === 0 ? undefined : decIrNodeIslandModules(r); + const o: NonNullable = { libnode: f0, nodeVersion: f1, ...(f2 !== undefined ? { modules: f2 } : {}) }; + return o; +} + +function encT10(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrFfiImport(w, item); + } +} +function decT10(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrFfiImport(r)); + return out; +} + +function encIrLibSection(w: IrWriter, v: IrLibSection): void { + w.keys(Object.keys(v), KEYS3); + w.str(v.profileName); + w.str(v.prefix); + w.str(v.initSymbol); + w.str(v.sinkRegisterSymbol); + if (v.collectSymbol === null) w.byte(0); + else { + w.byte(1); + w.str(v.collectSymbol); + } + if (v.resultResetSymbol === null) w.byte(0); + else { + w.byte(1); + w.str(v.resultResetSymbol); + } + w.bool(v.threadInstances); + if (v.callbackRegisterSymbol === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.callbackRegisterSymbol); + } + if (v.callbacks === undefined) w.byte(0); + else { + w.byte(1); + encT29(w, v.callbacks); + } + encT30(w, v.exports); + encT31(w, v.trapOverlays); + if (v.identity === undefined) w.byte(0); + else { + w.byte(1); + encIrLibIdentity(w, v.identity); + } +} +function decIrLibSection(r: IrReader): IrLibSection { + const f0 = r.str(); + const f1 = r.str(); + const f2 = r.str(); + const f3 = r.str(); + const f4 = r.tag(2, "IrModule.lib.collectSymbol") === 0 ? null : r.str(); + const f5 = r.tag(2, "IrModule.lib.resultResetSymbol") === 0 ? null : r.str(); + const f6 = r.bool(); + const f7 = r.tag(2, "IrModule.lib.callbackRegisterSymbol") === 0 ? undefined : r.str(); + const f8 = r.tag(2, "IrModule.lib.callbacks") === 0 ? undefined : decT29(r); + const f9 = decT30(r); + const f10 = decT31(r); + const f11 = r.tag(2, "IrModule.lib.identity") === 0 ? undefined : decIrLibIdentity(r); + const o: IrLibSection = { profileName: f0, prefix: f1, initSymbol: f2, sinkRegisterSymbol: f3, collectSymbol: f4, resultResetSymbol: f5, threadInstances: f6, ...(f7 !== undefined ? { callbackRegisterSymbol: f7 } : {}), ...(f8 !== undefined ? { callbacks: f8 } : {}), exports: f9, trapOverlays: f10, ...(f11 !== undefined ? { identity: f11 } : {}) }; + return o; +} + +function encIrContracts(w: IrWriter, v: IrContracts): void { + w.keys(Object.keys(v), KEYS4); + if (v.int32Fields === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.int32Fields); + } + if (v.int32Params === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.int32Params); + } + if (v.int32Returns === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.int32Returns); + } + if (v.int32Locals === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.int32Locals); + } + if (v.int32Elements === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.int32Elements); + } + if (v.noThrow === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.noThrow); + } + if (v.acyclic === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.acyclic); + } + if (v.immortalGlobals === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.immortalGlobals); + } + if (v.programLifetime === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.programLifetime); + } + if (v.inlineArrays === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.inlineArrays); + } +} +function decIrContracts(r: IrReader): IrContracts { + const f0 = r.tag(2, "IrModule.contracts.int32Fields") === 0 ? undefined : decT8(r); + const f1 = r.tag(2, "IrModule.contracts.int32Params") === 0 ? undefined : decT8(r); + const f2 = r.tag(2, "IrModule.contracts.int32Returns") === 0 ? undefined : decT8(r); + const f3 = r.tag(2, "IrModule.contracts.int32Locals") === 0 ? undefined : decT8(r); + const f4 = r.tag(2, "IrModule.contracts.int32Elements") === 0 ? undefined : decT8(r); + const f5 = r.tag(2, "IrModule.contracts.noThrow") === 0 ? undefined : decT8(r); + const f6 = r.tag(2, "IrModule.contracts.acyclic") === 0 ? undefined : decT8(r); + const f7 = r.tag(2, "IrModule.contracts.immortalGlobals") === 0 ? undefined : decT8(r); + const f8 = r.tag(2, "IrModule.contracts.programLifetime") === 0 ? undefined : decT8(r); + const f9 = r.tag(2, "IrModule.contracts.inlineArrays") === 0 ? undefined : decT8(r); + const o: IrContracts = { ...(f0 !== undefined ? { int32Fields: f0 } : {}), ...(f1 !== undefined ? { int32Params: f1 } : {}), ...(f2 !== undefined ? { int32Returns: f2 } : {}), ...(f3 !== undefined ? { int32Locals: f3 } : {}), ...(f4 !== undefined ? { int32Elements: f4 } : {}), ...(f5 !== undefined ? { noThrow: f5 } : {}), ...(f6 !== undefined ? { acyclic: f6 } : {}), ...(f7 !== undefined ? { immortalGlobals: f7 } : {}), ...(f8 !== undefined ? { programLifetime: f8 } : {}), ...(f9 !== undefined ? { inlineArrays: f9 } : {}) }; + return o; +} + +function encT13(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrWasmModule(w, item); + } +} +function decT13(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrWasmModule(r)); + return out; +} + +function encT14(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrWasmUntranslated(w, item); + } +} +function decT14(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrWasmUntranslated(r)); + return out; +} + +function encT15(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrParam(w, item); + } +} +function decT15(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrParam(r)); + return out; +} + +function encIrType(w: IrWriter, v: IrType): void { + switch (v.kind) { + case "array": { + w.length(0); + w.keys(Object.keys(v), KEYS5); + encIrType(w, v.elem); + if (v.optionalElements === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.optionalElements); + } + return; + } + case "bigint": { + w.length(1); + w.keys(Object.keys(v), KEYS6); + return; + } + case "bool": { + w.length(2); + w.keys(Object.keys(v), KEYS7); + return; + } + case "bytes": { + w.length(3); + w.keys(Object.keys(v), KEYS8); + w.str(v.elem); + return; + } + case "caught": { + w.length(4); + w.keys(Object.keys(v), KEYS9); + return; + } + case "child": { + w.length(5); + w.keys(Object.keys(v), KEYS10); + return; + } + case "childStream": { + w.length(6); + w.keys(Object.keys(v), KEYS11); + return; + } + case "childWriter": { + w.length(7); + w.keys(Object.keys(v), KEYS12); + return; + } + case "classval": { + w.length(8); + w.keys(Object.keys(v), KEYS13); + w.str(v.className); + return; + } + case "cryptoHash": { + w.length(9); + w.keys(Object.keys(v), KEYS14); + return; + } + case "cryptoHmac": { + w.length(10); + w.keys(Object.keys(v), KEYS15); + return; + } + case "date": { + w.length(11); + w.keys(Object.keys(v), KEYS16); + return; + } + case "dgramSocket": { + w.length(12); + w.keys(Object.keys(v), KEYS17); + return; + } + case "dir": { + w.length(13); + w.keys(Object.keys(v), KEYS18); + return; + } + case "dyn": { + w.length(14); + w.keys(Object.keys(v), KEYS19); + return; + } + case "f64": { + w.length(15); + w.keys(Object.keys(v), KEYS20); + return; + } + case "fileHandle": { + w.length(16); + w.keys(Object.keys(v), KEYS21); + return; + } + case "fsWatcher": { + w.length(17); + w.keys(Object.keys(v), KEYS22); + return; + } + case "func": { + w.length(18); + w.keys(Object.keys(v), KEYS23); + encT36(w, v.params); + encIrType(w, v.ret); + if (v.rest === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.rest); + } + if (v.restAbi === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.restAbi); + } + if (v.argumentsAll === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.argumentsAll); + } + return; + } + case "generator": { + w.length(19); + w.keys(Object.keys(v), KEYS24); + if (v.async === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.async); + } + encIrType(w, v.yieldT); + encIrType(w, v.retT); + encIrType(w, v.nextT); + return; + } + case "http2Session": { + w.length(20); + w.keys(Object.keys(v), KEYS25); + return; + } + case "http2Stream": { + w.length(21); + w.keys(Object.keys(v), KEYS26); + return; + } + case "httpClientReq": { + w.length(22); + w.keys(Object.keys(v), KEYS27); + return; + } + case "httpReq": { + w.length(23); + w.keys(Object.keys(v), KEYS28); + return; + } + case "httpRes": { + w.length(24); + w.keys(Object.keys(v), KEYS29); + return; + } + case "jsval": { + w.length(25); + w.keys(Object.keys(v), KEYS30); + return; + } + case "map": { + w.length(26); + w.keys(Object.keys(v), KEYS31); + encIrType(w, v.key); + encIrType(w, v.value); + return; + } + case "moduleNs": { + w.length(27); + w.keys(Object.keys(v), KEYS32); + w.str(v.moduleId); + return; + } + case "netServer": { + w.length(28); + w.keys(Object.keys(v), KEYS33); + return; + } + case "netSocket": { + w.length(29); + w.keys(Object.keys(v), KEYS34); + return; + } + case "nullT": { + w.length(30); + w.keys(Object.keys(v), KEYS35); + return; + } + case "object": { + w.length(31); + w.keys(Object.keys(v), KEYS36); + w.str(v.className); + return; + } + case "procStream": { + w.length(32); + w.keys(Object.keys(v), KEYS37); + return; + } + case "promise": { + w.length(33); + w.keys(Object.keys(v), KEYS38); + encIrType(w, v.inner); + return; + } + case "record": { + w.length(34); + w.keys(Object.keys(v), KEYS39); + w.str(v.shapeId); + return; + } + case "regex": { + w.length(35); + w.keys(Object.keys(v), KEYS40); + return; + } + case "searchParams": { + w.length(36); + w.keys(Object.keys(v), KEYS41); + return; + } + case "secureCtx": { + w.length(37); + w.keys(Object.keys(v), KEYS42); + return; + } + case "set": { + w.length(38); + w.keys(Object.keys(v), KEYS43); + encIrType(w, v.elem); + return; + } + case "spawnRes": { + w.length(39); + w.keys(Object.keys(v), KEYS44); + return; + } + case "stats": { + w.length(40); + w.keys(Object.keys(v), KEYS45); + return; + } + case "string": { + w.length(41); + w.keys(Object.keys(v), KEYS46); + return; + } + case "symbol": { + w.length(42); + w.keys(Object.keys(v), KEYS47); + return; + } + case "testCtx": { + w.length(43); + w.keys(Object.keys(v), KEYS48); + return; + } + case "undefinedT": { + w.length(44); + w.keys(Object.keys(v), KEYS49); + return; + } + case "union": { + w.length(45); + w.keys(Object.keys(v), KEYS50); + w.str(v.unionId); + return; + } + case "url": { + w.length(46); + w.keys(Object.keys(v), KEYS51); + return; + } + case "void": { + w.length(47); + w.keys(Object.keys(v), KEYS52); + return; + } + } + w.refuse("IrType kind " + String((v as { kind: unknown }).kind)); +} +function decIrType(r: IrReader): IrType { + switch (r.length()) { + case 0: { + const f0 = decIrType(r); + const f1 = r.tag(2, "IrFunction.returnType.optionalElements") === 0 ? undefined : (r.bool() as NonNullable["optionalElements"]>); + const o: Extract = { kind: "array", elem: f0, ...(f1 !== undefined ? { optionalElements: f1 } : {}) }; + return o; + } + case 1: { + const o: Extract = { kind: "bigint" }; + return o; + } + case 2: { + const o: Extract = { kind: "bool" }; + return o; + } + case 3: { + const f0 = (r.str() as NonNullable["elem"]>); + const o: Extract = { kind: "bytes", elem: f0 }; + return o; + } + case 4: { + const o: Extract = { kind: "caught" }; + return o; + } + case 5: { + const o: Extract = { kind: "child" }; + return o; + } + case 6: { + const o: Extract = { kind: "childStream" }; + return o; + } + case 7: { + const o: Extract = { kind: "childWriter" }; + return o; + } + case 8: { + const f0 = r.str(); + const o: Extract = { kind: "classval", className: f0 }; + return o; + } + case 9: { + const o: Extract = { kind: "cryptoHash" }; + return o; + } + case 10: { + const o: Extract = { kind: "cryptoHmac" }; + return o; + } + case 11: { + const o: Extract = { kind: "date" }; + return o; + } + case 12: { + const o: Extract = { kind: "dgramSocket" }; + return o; + } + case 13: { + const o: Extract = { kind: "dir" }; + return o; + } + case 14: { + const o: Extract = { kind: "dyn" }; + return o; + } + case 15: { + const o: Extract = { kind: "f64" }; + return o; + } + case 16: { + const o: Extract = { kind: "fileHandle" }; + return o; + } + case 17: { + const o: Extract = { kind: "fsWatcher" }; + return o; + } + case 18: { + const f0 = decT36(r); + const f1 = decIrType(r); + const f2 = r.tag(2, "IrFunction.returnType.rest") === 0 ? undefined : (r.bool() as NonNullable["rest"]>); + const f3 = r.tag(2, "IrFunction.returnType.restAbi") === 0 ? undefined : (r.str() as NonNullable["restAbi"]>); + const f4 = r.tag(2, "IrFunction.returnType.argumentsAll") === 0 ? undefined : (r.bool() as NonNullable["argumentsAll"]>); + const o: Extract = { kind: "func", params: f0, ret: f1, ...(f2 !== undefined ? { rest: f2 } : {}), ...(f3 !== undefined ? { restAbi: f3 } : {}), ...(f4 !== undefined ? { argumentsAll: f4 } : {}) }; + return o; + } + case 19: { + const f0 = r.tag(2, "IrFunction.returnType.async") === 0 ? undefined : (r.bool() as NonNullable["async"]>); + const f1 = decIrType(r); + const f2 = decIrType(r); + const f3 = decIrType(r); + const o: Extract = { kind: "generator", ...(f0 !== undefined ? { async: f0 } : {}), yieldT: f1, retT: f2, nextT: f3 }; + return o; + } + case 20: { + const o: Extract = { kind: "http2Session" }; + return o; + } + case 21: { + const o: Extract = { kind: "http2Stream" }; + return o; + } + case 22: { + const o: Extract = { kind: "httpClientReq" }; + return o; + } + case 23: { + const o: Extract = { kind: "httpReq" }; + return o; + } + case 24: { + const o: Extract = { kind: "httpRes" }; + return o; + } + case 25: { + const o: Extract = { kind: "jsval" }; + return o; + } + case 26: { + const f0 = decIrType(r); + const f1 = decIrType(r); + const o: Extract = { kind: "map", key: f0, value: f1 }; + return o; + } + case 27: { + const f0 = r.str(); + const o: Extract = { kind: "moduleNs", moduleId: f0 }; + return o; + } + case 28: { + const o: Extract = { kind: "netServer" }; + return o; + } + case 29: { + const o: Extract = { kind: "netSocket" }; + return o; + } + case 30: { + const o: Extract = { kind: "nullT" }; + return o; + } + case 31: { + const f0 = r.str(); + const o: Extract = { kind: "object", className: f0 }; + return o; + } + case 32: { + const o: Extract = { kind: "procStream" }; + return o; + } + case 33: { + const f0 = decIrType(r); + const o: Extract = { kind: "promise", inner: f0 }; + return o; + } + case 34: { + const f0 = r.str(); + const o: Extract = { kind: "record", shapeId: f0 }; + return o; + } + case 35: { + const o: Extract = { kind: "regex" }; + return o; + } + case 36: { + const o: Extract = { kind: "searchParams" }; + return o; + } + case 37: { + const o: Extract = { kind: "secureCtx" }; + return o; + } + case 38: { + const f0 = decIrType(r); + const o: Extract = { kind: "set", elem: f0 }; + return o; + } + case 39: { + const o: Extract = { kind: "spawnRes" }; + return o; + } + case 40: { + const o: Extract = { kind: "stats" }; + return o; + } + case 41: { + const o: Extract = { kind: "string" }; + return o; + } + case 42: { + const o: Extract = { kind: "symbol" }; + return o; + } + case 43: { + const o: Extract = { kind: "testCtx" }; + return o; + } + case 44: { + const o: Extract = { kind: "undefinedT" }; + return o; + } + case 45: { + const f0 = r.str(); + const o: Extract = { kind: "union", unionId: f0 }; + return o; + } + case 46: { + const o: Extract = { kind: "url" }; + return o; + } + case 47: { + const o: Extract = { kind: "void" }; + return o; + } + } + throw new Error("IR codec: invalid IrType kind"); +} + +function encT17(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrLocal(w, item); + } +} +function decT17(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrLocal(r)); + return out; +} + +function encT18(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT38(w, item); + } +} +function decT18(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT38(r)); + return out; +} + +function encT19(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrStmt(w, item); + } +} +function decT19(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrStmt(r)); + return out; +} + +function encSrcLoc(w: IrWriter, v: SrcLoc): void { + w.keys(Object.keys(v), KEYS53); + w.file(v.file); + w.num(v.start); + w.num(v.end); +} +function decSrcLoc(r: IrReader): SrcLoc { + const f0 = r.str(); + const f1 = r.num(); + const f2 = r.num(); + const o: SrcLoc = { file: f0, start: f1, end: f2 }; + return o; +} + +function encT21(w: IrWriter, v: NonNullable): void { + w.keys(Object.keys(v), KEYS54); + encIrType(w, v.yieldT); + encIrType(w, v.nextT); + encT40(w, v.resultType); + if (v.closesSource === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.closesSource); + } + if (v.tag === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.tag); + } + if (v.inheritsTag === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.inheritsTag); + } +} +function decT21(r: IrReader): NonNullable { + const f0 = decIrType(r); + const f1 = decIrType(r); + const f2 = decT40(r); + const f3 = r.tag(2, "IrFunction.generator.closesSource") === 0 ? undefined : (r.bool() as NonNullable["closesSource"]>); + const f4 = r.tag(2, "IrFunction.generator.tag") === 0 ? undefined : r.str(); + const f5 = r.tag(2, "IrFunction.generator.inheritsTag") === 0 ? undefined : (r.bool() as NonNullable["inheritsTag"]>); + const o: NonNullable = { yieldT: f0, nextT: f1, resultType: f2, ...(f3 !== undefined ? { closesSource: f3 } : {}), ...(f4 !== undefined ? { tag: f4 } : {}), ...(f5 !== undefined ? { inheritsTag: f5 } : {}) }; + return o; +} + +function encIrClassDef(w: IrWriter, v: IrClassDef): void { + w.keys(Object.keys(v), KEYS55); + w.str(v.name); + if (v.localCaptures === undefined) w.byte(0); + else { + w.byte(1); + encT15(w, v.localCaptures); + } + if (v.localBaseCapture === undefined) w.byte(0); + else { + w.byte(1); + w.num(v.localBaseCapture); + } + if (v.baseValueGlobal === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.baseValueGlobal); + } + if (v.jsName === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.jsName); + } + if (v.jsLength === undefined) w.byte(0); + else { + w.byte(1); + w.num(v.jsLength); + } + if (v.ownStaticFields === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.ownStaticFields); + } + if (v.prototypeDataHelper === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.prototypeDataHelper); + } + if (v.instancePrototypeHelper === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.instancePrototypeHelper); + } + if (v.stringTagHelper === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.stringTagHelper); + } + if (v.tracksOwnFields === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.tracksOwnFields); + } + if (v.errorNameAfter === undefined) w.byte(0); + else if (v.errorNameAfter === null) w.byte(1); + else { + w.byte(2); + w.str(v.errorNameAfter); + } + if (v.errorBagOrder === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.errorBagOrder); + } + if (v.symbolFields === undefined) w.byte(0); + else { + w.byte(1); + encT41(w, v.symbolFields); + } + if (v.runtime === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.runtime); + } + if (v.arrayElem === undefined) w.byte(0); + else { + w.byte(1); + encIrType(w, v.arrayElem); + } + if (v.arrayFields === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.arrayFields); + } + if (v.arraySpecies === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.arraySpecies); + } + if (v.base === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.base); + } + encT42(w, v.fields); + if (v.methods === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.methods); + } + if (v.prototypeNames === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.prototypeNames); + } + if (v.abstract === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.abstract); + } + if (v.abstractMethods === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.abstractMethods); + } + if (v.genericOf === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.genericOf); + } + encSrcLoc(w, v.loc); +} +function decIrClassDef(r: IrReader): IrClassDef { + const f0 = r.str(); + const f1 = r.tag(2, "IrModule.classes[].localCaptures") === 0 ? undefined : decT15(r); + const f2 = r.tag(2, "IrModule.classes[].localBaseCapture") === 0 ? undefined : r.num(); + const f3 = r.tag(2, "IrModule.classes[].baseValueGlobal") === 0 ? undefined : r.str(); + const f4 = r.tag(2, "IrModule.classes[].jsName") === 0 ? undefined : r.str(); + const f5 = r.tag(2, "IrModule.classes[].jsLength") === 0 ? undefined : r.num(); + const f6 = r.tag(2, "IrModule.classes[].ownStaticFields") === 0 ? undefined : (r.bool() as NonNullable); + const f7 = r.tag(2, "IrModule.classes[].prototypeDataHelper") === 0 ? undefined : r.str(); + const f8 = r.tag(2, "IrModule.classes[].instancePrototypeHelper") === 0 ? undefined : r.str(); + const f9 = r.tag(2, "IrModule.classes[].stringTagHelper") === 0 ? undefined : r.str(); + const f10 = r.tag(2, "IrModule.classes[].tracksOwnFields") === 0 ? undefined : r.bool(); + const f11 = r.tag(3, "IrModule.classes[].errorNameAfter") === 0 ? undefined : r.lastTag === 1 ? null : r.str(); + const f12 = r.tag(2, "IrModule.classes[].errorBagOrder") === 0 ? undefined : r.bool(); + const f13 = r.tag(2, "IrModule.classes[].symbolFields") === 0 ? undefined : decT41(r); + const f14 = r.tag(2, "IrModule.classes[].runtime") === 0 ? undefined : (r.bool() as NonNullable); + const f15 = r.tag(2, "IrModule.classes[].arrayElem") === 0 ? undefined : decIrType(r); + const f16 = r.tag(2, "IrModule.classes[].arrayFields") === 0 ? undefined : r.str(); + const f17 = r.tag(2, "IrModule.classes[].arraySpecies") === 0 ? undefined : r.str(); + const f18 = r.tag(2, "IrModule.classes[].base") === 0 ? undefined : r.str(); + const f19 = decT42(r); + const f20 = r.tag(2, "IrModule.classes[].methods") === 0 ? undefined : decT8(r); + const f21 = r.tag(2, "IrModule.classes[].prototypeNames") === 0 ? undefined : decT8(r); + const f22 = r.tag(2, "IrModule.classes[].abstract") === 0 ? undefined : (r.bool() as NonNullable); + const f23 = r.tag(2, "IrModule.classes[].abstractMethods") === 0 ? undefined : decT8(r); + const f24 = r.tag(2, "IrModule.classes[].genericOf") === 0 ? undefined : r.str(); + const f25 = decSrcLoc(r); + const o: IrClassDef = { name: f0, ...(f1 !== undefined ? { localCaptures: f1 } : {}), ...(f2 !== undefined ? { localBaseCapture: f2 } : {}), ...(f3 !== undefined ? { baseValueGlobal: f3 } : {}), ...(f4 !== undefined ? { jsName: f4 } : {}), ...(f5 !== undefined ? { jsLength: f5 } : {}), ...(f6 !== undefined ? { ownStaticFields: f6 } : {}), ...(f7 !== undefined ? { prototypeDataHelper: f7 } : {}), ...(f8 !== undefined ? { instancePrototypeHelper: f8 } : {}), ...(f9 !== undefined ? { stringTagHelper: f9 } : {}), ...(f10 !== undefined ? { tracksOwnFields: f10 } : {}), ...(f11 !== undefined ? { errorNameAfter: f11 } : {}), ...(f12 !== undefined ? { errorBagOrder: f12 } : {}), ...(f13 !== undefined ? { symbolFields: f13 } : {}), ...(f14 !== undefined ? { runtime: f14 } : {}), ...(f15 !== undefined ? { arrayElem: f15 } : {}), ...(f16 !== undefined ? { arrayFields: f16 } : {}), ...(f17 !== undefined ? { arraySpecies: f17 } : {}), ...(f18 !== undefined ? { base: f18 } : {}), fields: f19, ...(f20 !== undefined ? { methods: f20 } : {}), ...(f21 !== undefined ? { prototypeNames: f21 } : {}), ...(f22 !== undefined ? { abstract: f22 } : {}), ...(f23 !== undefined ? { abstractMethods: f23 } : {}), ...(f24 !== undefined ? { genericOf: f24 } : {}), loc: f25 }; + return o; +} + +function encIrGlobal(w: IrWriter, v: IrGlobal): void { + w.keys(Object.keys(v), KEYS56); + w.str(v.id); + w.str(v.name); + encIrType(w, v.type); + w.bool(v.mutable); + if (v.tdz === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.tdz); + } + if (v.initFlag === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.initFlag); + } + if (v.source === undefined) w.byte(0); + else { + w.byte(1); + encIrBindingSource(w, v.source); + } +} +function decIrGlobal(r: IrReader): IrGlobal { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = r.bool(); + const f4 = r.tag(2, "IrModule.globals[].tdz") === 0 ? undefined : (r.bool() as NonNullable); + const f5 = r.tag(2, "IrModule.globals[].initFlag") === 0 ? undefined : r.str(); + const f6 = r.tag(2, "IrModule.globals[].source") === 0 ? undefined : decIrBindingSource(r); + const o: IrGlobal = { id: f0, name: f1, type: f2, mutable: f3, ...(f4 !== undefined ? { tdz: f4 } : {}), ...(f5 !== undefined ? { initFlag: f5 } : {}), ...(f6 !== undefined ? { source: f6 } : {}) }; + return o; +} + +function encIrRecordShape(w: IrWriter, v: IrRecordShape): void { + w.keys(Object.keys(v), KEYS57); + w.str(v.id); + encT44(w, v.fields); + if (v.tuple === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.tuple); + } + if (v.indexValue === undefined) w.byte(0); + else { + w.byte(1); + encIrType(w, v.indexValue); + } + if (v.freezable === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.freezable); + } + if (v.declaredOrder === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.declaredOrder); + } + if (v.accessorOrder === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.accessorOrder); + } + if (v.orderVariants === undefined) w.byte(0); + else { + w.byte(1); + encT45(w, v.orderVariants); + } +} +function decIrRecordShape(r: IrReader): IrRecordShape { + const f0 = r.str(); + const f1 = decT44(r); + const f2 = r.tag(2, "IrModule.records[].tuple") === 0 ? undefined : (r.bool() as NonNullable); + const f3 = r.tag(2, "IrModule.records[].indexValue") === 0 ? undefined : decIrType(r); + const f4 = r.tag(2, "IrModule.records[].freezable") === 0 ? undefined : (r.bool() as NonNullable); + const f5 = r.tag(2, "IrModule.records[].declaredOrder") === 0 ? undefined : decT8(r); + const f6 = r.tag(2, "IrModule.records[].accessorOrder") === 0 ? undefined : decT8(r); + const f7 = r.tag(2, "IrModule.records[].orderVariants") === 0 ? undefined : decT45(r); + const o: IrRecordShape = { id: f0, fields: f1, ...(f2 !== undefined ? { tuple: f2 } : {}), ...(f3 !== undefined ? { indexValue: f3 } : {}), ...(f4 !== undefined ? { freezable: f4 } : {}), ...(f5 !== undefined ? { declaredOrder: f5 } : {}), ...(f6 !== undefined ? { accessorOrder: f6 } : {}), ...(f7 !== undefined ? { orderVariants: f7 } : {}) }; + return o; +} + +function encIrUnionDef(w: IrWriter, v: IrUnionDef): void { + w.keys(Object.keys(v), KEYS58); + w.str(v.id); + encT36(w, v.arms); + if (v.discriminant === undefined) w.byte(0); + else { + w.byte(1); + encIrUnionDiscriminant(w, v.discriminant); + } +} +function decIrUnionDef(r: IrReader): IrUnionDef { + const f0 = r.str(); + const f1 = decT36(r); + const f2 = r.tag(2, "IrModule.unions[].discriminant") === 0 ? undefined : decIrUnionDiscriminant(r); + const o: IrUnionDef = { id: f0, arms: f1, ...(f2 !== undefined ? { discriminant: f2 } : {}) }; + return o; +} + +function encT26(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS59); + w.file(v.file); + w.str(v.pkg); +} +function decT26(r: IrReader): NonNullable[number]> { + const f0 = r.str(); + const f1 = r.str(); + const o: NonNullable[number]> = { file: f0, pkg: f1 }; + return o; +} + +function encIrNodeIslandModules(w: IrWriter, v: IrNodeIslandModules): void { + w.keys(Object.keys(v), KEYS60); + w.str(v.root); + encT47(w, v.files); + encT48(w, v.links); + if (v.location === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.location); + } +} +function decIrNodeIslandModules(r: IrReader): IrNodeIslandModules { + const f0 = r.str(); + const f1 = decT47(r); + const f2 = decT48(r); + const f3 = r.tag(2, "IrModule.nodeIsland.modules.location") === 0 ? undefined : r.str(); + const o: IrNodeIslandModules = { root: f0, files: f1, links: f2, ...(f3 !== undefined ? { location: f3 } : {}) }; + return o; +} + +function encIrFfiImport(w: IrWriter, v: IrFfiImport): void { + w.keys(Object.keys(v), KEYS61); + w.str(v.name); + w.str(v.symbol); + if (v.library === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.library); + } + if (v.callbackOperation === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.callbackOperation); + } + if (v.callbackTarget === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.callbackTarget); + } + encT49(w, v.params); + w.str(v.returns); +} +function decIrFfiImport(r: IrReader): IrFfiImport { + const f0 = r.str(); + const f1 = r.str(); + const f2 = r.tag(2, "IrModule.ffiImports[].library") === 0 ? undefined : r.str(); + const f3 = r.tag(2, "IrModule.ffiImports[].callbackOperation") === 0 ? undefined : (r.str() as NonNullable); + const f4 = r.tag(2, "IrModule.ffiImports[].callbackTarget") === 0 ? undefined : r.str(); + const f5 = decT49(r); + const f6 = (r.str() as NonNullable); + const o: IrFfiImport = { name: f0, symbol: f1, ...(f2 !== undefined ? { library: f2 } : {}), ...(f3 !== undefined ? { callbackOperation: f3 } : {}), ...(f4 !== undefined ? { callbackTarget: f4 } : {}), params: f5, returns: f6 }; + return o; +} + +function encT29(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrLibCallback(w, item); + } +} +function decT29(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrLibCallback(r)); + return out; +} + +function encT30(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrLibExport(w, item); + } +} +function decT30(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrLibExport(r)); + return out; +} + +function encT31(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrLibTrapOverlay(w, item); + } +} +function decT31(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrLibTrapOverlay(r)); + return out; +} + +function encIrLibIdentity(w: IrWriter, v: IrLibIdentity): void { + w.keys(Object.keys(v), KEYS62); + w.str(v.buildIdSymbol); + w.str(v.abiVersionSymbol); + w.str(v.buildId); + w.num(v.abiVersion); +} +function decIrLibIdentity(r: IrReader): IrLibIdentity { + const f0 = r.str(); + const f1 = r.str(); + const f2 = r.str(); + const f3 = r.num(); + const o: IrLibIdentity = { buildIdSymbol: f0, abiVersionSymbol: f1, buildId: f2, abiVersion: f3 }; + return o; +} + +function encIrWasmModule(w: IrWriter, v: IrWasmModule): void { + w.keys(Object.keys(v), KEYS63); + w.str(v.sha256); + w.str(v.bytes); +} +function decIrWasmModule(r: IrReader): IrWasmModule { + const f0 = r.str(); + const f1 = r.str(); + const o: IrWasmModule = { sha256: f0, bytes: f1 }; + return o; +} + +function encIrWasmUntranslated(w: IrWriter, v: IrWasmUntranslated): void { + w.keys(Object.keys(v), KEYS64); + w.str(v.sha256); + w.num(v.size); + w.str(v.reason); +} +function decIrWasmUntranslated(r: IrReader): IrWasmUntranslated { + const f0 = r.str(); + const f1 = r.num(); + const f2 = r.str(); + const o: IrWasmUntranslated = { sha256: f0, size: f1, reason: f2 }; + return o; +} + +function encIrParam(w: IrWriter, v: IrParam): void { + w.keys(Object.keys(v), KEYS65); + w.str(v.localId); + w.str(v.name); + encIrType(w, v.type); + if (v.owned === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.owned); + } +} +function decIrParam(r: IrReader): IrParam { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = r.tag(2, "IrFunction.params[].owned") === 0 ? undefined : (r.bool() as NonNullable); + const o: IrParam = { localId: f0, name: f1, type: f2, ...(f3 !== undefined ? { owned: f3 } : {}) }; + return o; +} + +function encT36(w: IrWriter, v: NonNullable["params"]>): void { + w.length(v.length); + for (const item of v) { + encIrType(w, item); + } +} +function decT36(r: IrReader): NonNullable["params"]> { + const length = r.length(); + const out: NonNullable["params"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrType(r)); + return out; +} + +function encIrLocal(w: IrWriter, v: IrLocal): void { + w.keys(Object.keys(v), KEYS66); + w.str(v.id); + w.str(v.name); + encIrType(w, v.type); + w.bool(v.mutable); + if (v.source === undefined) w.byte(0); + else { + w.byte(1); + encIrBindingSource(w, v.source); + } + if (v.boxed === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.boxed); + } + if (v.tdz === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.tdz); + } +} +function decIrLocal(r: IrReader): IrLocal { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = r.bool(); + const f4 = r.tag(2, "IrFunction.locals[].source") === 0 ? undefined : decIrBindingSource(r); + const f5 = r.tag(2, "IrFunction.locals[].boxed") === 0 ? undefined : (r.bool() as NonNullable); + const f6 = r.tag(2, "IrFunction.locals[].tdz") === 0 ? undefined : (r.bool() as NonNullable); + const o: IrLocal = { id: f0, name: f1, type: f2, mutable: f3, ...(f4 !== undefined ? { source: f4 } : {}), ...(f5 !== undefined ? { boxed: f5 } : {}), ...(f6 !== undefined ? { tdz: f6 } : {}) }; + return o; +} + +function encT38(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS67); + w.str(v.localId); + w.str(v.name); + encIrType(w, v.type); + if (v.owned === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.owned); + } + w.num(v.slot); +} +function decT38(r: IrReader): NonNullable[number]> { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = r.tag(2, "IrFunction.classCaptures[].owned") === 0 ? undefined : (r.bool() as NonNullable[number]>["owned"]>); + const f4 = r.num(); + const o: NonNullable[number]> = { localId: f0, name: f1, type: f2, ...(f3 !== undefined ? { owned: f3 } : {}), slot: f4 }; + return o; +} + +function encIrStmt(w: IrWriter, v: IrStmt): void { + switch (v.kind) { + case "arrayDelete": { + w.length(0); + w.keys(Object.keys(v), KEYS68); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encSrcLoc(w, v.loc); + return; + } + case "arraySet": { + w.length(1); + w.keys(Object.keys(v), KEYS69); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encIrExpr(w, v.value); + encSrcLoc(w, v.loc); + return; + } + case "arraySetLength": { + w.length(2); + w.keys(Object.keys(v), KEYS70); + encIrExpr(w, v.arr); + encIrExpr(w, v.length); + encSrcLoc(w, v.loc); + if (v.pop === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.pop); + } + if (v.reserve === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.reserve); + } + return; + } + case "arraySetUndefined": { + w.length(3); + w.keys(Object.keys(v), KEYS71); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encSrcLoc(w, v.loc); + return; + } + case "assign": { + w.length(4); + w.keys(Object.keys(v), KEYS72); + w.str(v.localId); + encIrExpr(w, v.value); + if (v.initializes === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.initializes); + } + encSrcLoc(w, v.loc); + return; + } + case "block": { + w.length(5); + w.keys(Object.keys(v), KEYS73); + if (v.labels === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.labels); + } + encT19(w, v.body); + encSrcLoc(w, v.loc); + return; + } + case "break": { + w.length(6); + w.keys(Object.keys(v), KEYS74); + if (v.label === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.label); + } + encSrcLoc(w, v.loc); + return; + } + case "bytesSet": { + w.length(7); + w.keys(Object.keys(v), KEYS75); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encIrExpr(w, v.value); + encSrcLoc(w, v.loc); + return; + } + case "continue": { + w.length(8); + w.keys(Object.keys(v), KEYS76); + if (v.label === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.label); + } + encSrcLoc(w, v.loc); + return; + } + case "doWhile": { + w.length(9); + w.keys(Object.keys(v), KEYS77); + encT19(w, v.body); + encIrExpr(w, v.cond); + if (v.labels === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.labels); + } + encSrcLoc(w, v.loc); + return; + } + case "exprStmt": { + w.length(10); + w.keys(Object.keys(v), KEYS78); + encIrExpr(w, v.expr); + encSrcLoc(w, v.loc); + return; + } + case "fieldSet": { + w.length(11); + w.keys(Object.keys(v), KEYS79); + encIrExpr(w, v.obj); + w.str(v.className); + w.str(v.field); + encIrExpr(w, v.value); + if (v.sloppy === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.sloppy); + } + if (v.define === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.define); + } + if (v.attributesChecked === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.attributesChecked); + } + encSrcLoc(w, v.loc); + return; + } + case "for": { + w.length(12); + w.keys(Object.keys(v), KEYS80); + if (v.init === null) w.byte(0); + else { + w.byte(1); + encIrStmt(w, v.init); + } + if (v.cond === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.cond); + } + if (v.update === null) w.byte(0); + else { + w.byte(1); + encIrStmt(w, v.update); + } + encT19(w, v.body); + if (v.labels === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.labels); + } + encSrcLoc(w, v.loc); + return; + } + case "forOf": { + w.length(13); + w.keys(Object.keys(v), KEYS81); + w.str(v.localId); + encIrExpr(w, v.iterable); + encT19(w, v.body); + if (v.labels === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.labels); + } + encSrcLoc(w, v.loc); + return; + } + case "if": { + w.length(14); + w.keys(Object.keys(v), KEYS82); + encIrExpr(w, v.cond); + encT19(w, v.then); + if (v.else_ === null) w.byte(0); + else { + w.byte(1); + encT19(w, v.else_); + } + encSrcLoc(w, v.loc); + return; + } + case "recordKeyDelete": { + w.length(15); + w.keys(Object.keys(v), KEYS83); + encIrExpr(w, v.obj); + w.str(v.shapeId); + encIrExpr(w, v.key); + encSrcLoc(w, v.loc); + return; + } + case "recordKeySet": { + w.length(16); + w.keys(Object.keys(v), KEYS84); + encIrExpr(w, v.obj); + w.str(v.shapeId); + encIrExpr(w, v.key); + encIrExpr(w, v.value); + if (v.overflowOnly === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.overflowOnly); + } + encSrcLoc(w, v.loc); + return; + } + case "recordSet": { + w.length(17); + w.keys(Object.keys(v), KEYS85); + encIrExpr(w, v.obj); + w.str(v.shapeId); + w.str(v.field); + encIrExpr(w, v.value); + encSrcLoc(w, v.loc); + return; + } + case "rethrow": { + w.length(18); + w.keys(Object.keys(v), KEYS86); + w.str(v.localId); + encSrcLoc(w, v.loc); + return; + } + case "return": { + w.length(19); + w.keys(Object.keys(v), KEYS87); + if (v.value === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.value); + } + if (v.awaited === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.awaited); + } + encSrcLoc(w, v.loc); + return; + } + case "runtimeFence": { + w.length(20); + w.keys(Object.keys(v), KEYS88); + w.str(v.code); + w.str(v.message); + encSrcLoc(w, v.loc); + return; + } + case "switch": { + w.length(21); + w.keys(Object.keys(v), KEYS89); + encIrExpr(w, v.disc); + encT54(w, v.cases); + if (v.labels === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.labels); + } + encSrcLoc(w, v.loc); + return; + } + case "throw": { + w.length(22); + w.keys(Object.keys(v), KEYS90); + encIrExpr(w, v.value); + encSrcLoc(w, v.loc); + return; + } + case "tryCatch": { + w.length(23); + w.keys(Object.keys(v), KEYS91); + encT19(w, v.tryBody); + if (v.catchBody === null) w.byte(0); + else { + w.byte(1); + encT19(w, v.catchBody); + } + if (v.catchLocalId === null) w.byte(0); + else { + w.byte(1); + w.str(v.catchLocalId); + } + if (v.dropFinallyErrors === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.dropFinallyErrors); + } + if (v.finallyBody === null) w.byte(0); + else { + w.byte(1); + encT19(w, v.finallyBody); + } + if (v.suppressFinallyErrors === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.suppressFinallyErrors); + } + encSrcLoc(w, v.loc); + return; + } + case "varDecl": { + w.length(24); + w.keys(Object.keys(v), KEYS92); + w.str(v.localId); + if (v.init === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.init); + } + encSrcLoc(w, v.loc); + return; + } + case "while": { + w.length(25); + w.keys(Object.keys(v), KEYS93); + encIrExpr(w, v.cond); + encT19(w, v.body); + if (v.labels === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.labels); + } + encSrcLoc(w, v.loc); + return; + } + } + w.refuse("IrStmt kind " + String((v as { kind: unknown }).kind)); +} +function decIrStmt(r: IrReader): IrStmt { + switch (r.length()) { + case 0: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "arrayDelete", arr: f0, index: f1, loc: f2 }; + return o; + } + case 1: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "arraySet", arr: f0, index: f1, value: f2, loc: f3 }; + return o; + } + case 2: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decSrcLoc(r); + const f3 = r.tag(2, "IrFunction.body[].pop") === 0 ? undefined : r.bool(); + const f4 = r.tag(2, "IrFunction.body[].reserve") === 0 ? undefined : r.bool(); + const o: Extract = { kind: "arraySetLength", arr: f0, length: f1, loc: f2, ...(f3 !== undefined ? { pop: f3 } : {}), ...(f4 !== undefined ? { reserve: f4 } : {}) }; + return o; + } + case 3: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "arraySetUndefined", arr: f0, index: f1, loc: f2 }; + return o; + } + case 4: { + const f0 = r.str(); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].initializes") === 0 ? undefined : (r.bool() as NonNullable["initializes"]>); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "assign", localId: f0, value: f1, ...(f2 !== undefined ? { initializes: f2 } : {}), loc: f3 }; + return o; + } + case 5: { + const f0 = r.tag(2, "IrFunction.body[].labels") === 0 ? undefined : decT8(r); + const f1 = decT19(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "block", ...(f0 !== undefined ? { labels: f0 } : {}), body: f1, loc: f2 }; + return o; + } + case 6: { + const f0 = r.tag(2, "IrFunction.body[].label") === 0 ? undefined : r.str(); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "break", ...(f0 !== undefined ? { label: f0 } : {}), loc: f1 }; + return o; + } + case 7: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "bytesSet", arr: f0, index: f1, value: f2, loc: f3 }; + return o; + } + case 8: { + const f0 = r.tag(2, "IrFunction.body[].label") === 0 ? undefined : r.str(); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "continue", ...(f0 !== undefined ? { label: f0 } : {}), loc: f1 }; + return o; + } + case 9: { + const f0 = decT19(r); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].labels") === 0 ? undefined : decT8(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "doWhile", body: f0, cond: f1, ...(f2 !== undefined ? { labels: f2 } : {}), loc: f3 }; + return o; + } + case 10: { + const f0 = decIrExpr(r); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "exprStmt", expr: f0, loc: f1 }; + return o; + } + case 11: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.str(); + const f3 = decIrExpr(r); + const f4 = r.tag(2, "IrFunction.body[]
.sloppy") === 0 ? undefined : (r.bool() as NonNullable["sloppy"]>); + const f5 = r.tag(2, "IrFunction.body[]
.define") === 0 ? undefined : (r.bool() as NonNullable["define"]>); + const f6 = r.tag(2, "IrFunction.body[]
.attributesChecked") === 0 ? undefined : (r.bool() as NonNullable["attributesChecked"]>); + const f7 = decSrcLoc(r); + const o: Extract = { kind: "fieldSet", obj: f0, className: f1, field: f2, value: f3, ...(f4 !== undefined ? { sloppy: f4 } : {}), ...(f5 !== undefined ? { define: f5 } : {}), ...(f6 !== undefined ? { attributesChecked: f6 } : {}), loc: f7 }; + return o; + } + case 12: { + const f0 = r.tag(2, "IrFunction.body[].init") === 0 ? null : decIrStmt(r); + const f1 = r.tag(2, "IrFunction.body[].cond") === 0 ? null : decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].update") === 0 ? null : decIrStmt(r); + const f3 = decT19(r); + const f4 = r.tag(2, "IrFunction.body[].labels") === 0 ? undefined : decT8(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "for", init: f0, cond: f1, update: f2, body: f3, ...(f4 !== undefined ? { labels: f4 } : {}), loc: f5 }; + return o; + } + case 13: { + const f0 = r.str(); + const f1 = decIrExpr(r); + const f2 = decT19(r); + const f3 = r.tag(2, "IrFunction.body[].labels") === 0 ? undefined : decT8(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "forOf", localId: f0, iterable: f1, body: f2, ...(f3 !== undefined ? { labels: f3 } : {}), loc: f4 }; + return o; + } + case 14: { + const f0 = decIrExpr(r); + const f1 = decT19(r); + const f2 = r.tag(2, "IrFunction.body[].else_") === 0 ? null : decT19(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "if", cond: f0, then: f1, else_: f2, loc: f3 }; + return o; + } + case 15: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrExpr(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "recordKeyDelete", obj: f0, shapeId: f1, key: f2, loc: f3 }; + return o; + } + case 16: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrExpr(r); + const f3 = decIrExpr(r); + const f4 = r.tag(2, "IrFunction.body[].overflowOnly") === 0 ? undefined : (r.bool() as NonNullable["overflowOnly"]>); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "recordKeySet", obj: f0, shapeId: f1, key: f2, value: f3, ...(f4 !== undefined ? { overflowOnly: f4 } : {}), loc: f5 }; + return o; + } + case 17: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.str(); + const f3 = decIrExpr(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "recordSet", obj: f0, shapeId: f1, field: f2, value: f3, loc: f4 }; + return o; + } + case 18: { + const f0 = r.str(); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "rethrow", localId: f0, loc: f1 }; + return o; + } + case 19: { + const f0 = r.tag(2, "IrFunction.body[].value") === 0 ? null : decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].awaited") === 0 ? undefined : (r.bool() as NonNullable["awaited"]>); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "return", value: f0, ...(f1 !== undefined ? { awaited: f1 } : {}), loc: f2 }; + return o; + } + case 20: { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "runtimeFence", code: f0, message: f1, loc: f2 }; + return o; + } + case 21: { + const f0 = decIrExpr(r); + const f1 = decT54(r); + const f2 = r.tag(2, "IrFunction.body[].labels") === 0 ? undefined : decT8(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "switch", disc: f0, cases: f1, ...(f2 !== undefined ? { labels: f2 } : {}), loc: f3 }; + return o; + } + case 22: { + const f0 = decIrExpr(r); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "throw", value: f0, loc: f1 }; + return o; + } + case 23: { + const f0 = decT19(r); + const f1 = r.tag(2, "IrFunction.body[].catchBody") === 0 ? null : decT19(r); + const f2 = r.tag(2, "IrFunction.body[].catchLocalId") === 0 ? null : r.str(); + const f3 = r.tag(2, "IrFunction.body[].dropFinallyErrors") === 0 ? undefined : (r.bool() as NonNullable["dropFinallyErrors"]>); + const f4 = r.tag(2, "IrFunction.body[].finallyBody") === 0 ? null : decT19(r); + const f5 = r.tag(2, "IrFunction.body[].suppressFinallyErrors") === 0 ? undefined : (r.bool() as NonNullable["suppressFinallyErrors"]>); + const f6 = decSrcLoc(r); + const o: Extract = { kind: "tryCatch", tryBody: f0, catchBody: f1, catchLocalId: f2, ...(f3 !== undefined ? { dropFinallyErrors: f3 } : {}), finallyBody: f4, ...(f5 !== undefined ? { suppressFinallyErrors: f5 } : {}), loc: f6 }; + return o; + } + case 24: { + const f0 = r.str(); + const f1 = r.tag(2, "IrFunction.body[].init") === 0 ? null : decIrExpr(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "varDecl", localId: f0, init: f1, loc: f2 }; + return o; + } + case 25: { + const f0 = decIrExpr(r); + const f1 = decT19(r); + const f2 = r.tag(2, "IrFunction.body[].labels") === 0 ? undefined : decT8(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "while", cond: f0, body: f1, ...(f2 !== undefined ? { labels: f2 } : {}), loc: f3 }; + return o; + } + } + throw new Error("IR codec: invalid IrStmt kind"); +} + +function encT40(w: IrWriter, v: NonNullable["resultType"]>): void { + w.keys(Object.keys(v), KEYS94); + w.str(v.kind); + w.str(v.shapeId); +} +function decT40(r: IrReader): NonNullable["resultType"]> { + const f0 = (r.str() as NonNullable["resultType"]>["kind"]>); + const f1 = r.str(); + const o: NonNullable["resultType"]> = { kind: f0, shapeId: f1 }; + return o; +} + +function encT41(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT55(w, item); + } +} +function decT41(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT55(r)); + return out; +} + +function encT42(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT56(w, item); + } +} +function decT42(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT56(r)); + return out; +} + +function encIrBindingSource(w: IrWriter, v: IrBindingSource): void { + w.keys(Object.keys(v), KEYS95); + encSrcLoc(w, v.loc); + encSrcLoc(w, v.scope); +} +function decIrBindingSource(r: IrReader): IrBindingSource { + const f0 = decSrcLoc(r); + const f1 = decSrcLoc(r); + const o: IrBindingSource = { loc: f0, scope: f1 }; + return o; +} + +function encT44(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT57(w, item); + } +} +function decT44(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT57(r)); + return out; +} + +function encT45(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT8(w, item); + } +} +function decT45(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT8(r)); + return out; +} + +function encIrUnionDiscriminant(w: IrWriter, v: IrUnionDiscriminant): void { + w.keys(Object.keys(v), KEYS96); + w.str(v.field); + encT58(w, v.cases); +} +function decIrUnionDiscriminant(r: IrReader): IrUnionDiscriminant { + const f0 = r.str(); + const f1 = decT58(r); + const o: IrUnionDiscriminant = { field: f0, cases: f1 }; + return o; +} + +function encT47(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT59(w, item); + } +} +function decT47(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT59(r)); + return out; +} + +function encT48(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT60(w, item); + } +} +function decT48(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT60(r)); + return out; +} + +function encT49(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encIrFfiParam(w, item); + } +} +function decT49(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrFfiParam(r)); + return out; +} + +function encIrLibCallback(w: IrWriter, v: IrLibCallback): void { + w.keys(Object.keys(v), KEYS97); + w.str(v.name); + w.num(v.slot); + encT62(w, v.params); + w.str(v.returns); + w.str(v.unregisteredTrap); +} +function decIrLibCallback(r: IrReader): IrLibCallback { + const f0 = r.str(); + const f1 = r.num(); + const f2 = decT62(r); + const f3 = (r.str() as NonNullable); + const f4 = r.str(); + const o: IrLibCallback = { name: f0, slot: f1, params: f2, returns: f3, unregisteredTrap: f4 }; + return o; +} + +function encIrLibExport(w: IrWriter, v: IrLibExport): void { + w.keys(Object.keys(v), KEYS98); + w.str(v.symbol); + w.str(v.fnName); + encT63(w, v.params); + w.str(v.returns); + if (v.inboundBytesTrap === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.inboundBytesTrap); + } + if (v.inboundIntTrap === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.inboundIntTrap); + } + if (v.inboundCstringTrap === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.inboundCstringTrap); + } +} +function decIrLibExport(r: IrReader): IrLibExport { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decT63(r); + const f3 = (r.str() as NonNullable); + const f4 = r.tag(2, "IrModule.lib.exports[].inboundBytesTrap") === 0 ? undefined : r.str(); + const f5 = r.tag(2, "IrModule.lib.exports[].inboundIntTrap") === 0 ? undefined : r.str(); + const f6 = r.tag(2, "IrModule.lib.exports[].inboundCstringTrap") === 0 ? undefined : r.str(); + const o: IrLibExport = { symbol: f0, fnName: f1, params: f2, returns: f3, ...(f4 !== undefined ? { inboundBytesTrap: f4 } : {}), ...(f5 !== undefined ? { inboundIntTrap: f5 } : {}), ...(f6 !== undefined ? { inboundCstringTrap: f6 } : {}) }; + return o; +} + +function encIrLibTrapOverlay(w: IrWriter, v: IrLibTrapOverlay): void { + w.keys(Object.keys(v), KEYS99); + w.str(v.code); + if (v.teaching === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.teaching); + } + if (v.remediation === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.remediation); + } +} +function decIrLibTrapOverlay(r: IrReader): IrLibTrapOverlay { + const f0 = r.str(); + const f1 = r.tag(2, "IrModule.lib.trapOverlays[].teaching") === 0 ? undefined : r.str(); + const f2 = r.tag(2, "IrModule.lib.trapOverlays[].remediation") === 0 ? undefined : r.str(); + const o: IrLibTrapOverlay = { code: f0, ...(f1 !== undefined ? { teaching: f1 } : {}), ...(f2 !== undefined ? { remediation: f2 } : {}) }; + return o; +} + +function encIrExpr(w: IrWriter, v: IrExpr): void { + switch (v.kind) { + case "arrIntrinsic": { + w.length(0); + w.keys(Object.keys(v), KEYS100); + w.str(v.method); + encIrExpr(w, v.receiver); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "arrayGet": { + w.length(1); + w.keys(Object.keys(v), KEYS101); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "arrayHas": { + w.length(2); + w.keys(Object.keys(v), KEYS102); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "arrayLit": { + w.length(3); + w.keys(Object.keys(v), KEYS103); + encT64(w, v.elems); + if (v.spreads === undefined) w.byte(0); + else { + w.byte(1); + encT65(w, v.spreads); + } + if (v.capacity === undefined) w.byte(0); + else { + w.byte(1); + w.num(v.capacity); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "arrayNewLen": { + w.length(4); + w.keys(Object.keys(v), KEYS104); + encIrExpr(w, v.length); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "arrayState": { + w.length(5); + w.keys(Object.keys(v), KEYS105); + encIrExpr(w, v.arr); + encIrExpr(w, v.index); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "assignExpr": { + w.length(6); + w.keys(Object.keys(v), KEYS106); + w.str(v.localId); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "awaitExpr": { + w.length(7); + w.keys(Object.keys(v), KEYS107); + encIrExpr(w, v.value); + encIrType(w, v.type); + if (v.adopt === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.adopt); + } + encSrcLoc(w, v.loc); + return; + } + case "awaitUnionExpr": { + w.length(8); + w.keys(Object.keys(v), KEYS108); + encIrExpr(w, v.value); + w.num(v.promiseTag); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "bin": { + w.length(9); + w.keys(Object.keys(v), KEYS109); + w.str(v.op); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "boolLit": { + w.length(10); + w.keys(Object.keys(v), KEYS110); + w.bool(v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "bytesIntrinsic": { + w.length(11); + w.keys(Object.keys(v), KEYS111); + w.str(v.method); + encIrExpr(w, v.receiver); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + if (v.invalidNaN === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.invalidNaN); + } + return; + } + case "bytesNew": { + w.length(12); + w.keys(Object.keys(v), KEYS112); + if (v.source === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.source); + } + if (v.from === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.from); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "call": { + w.length(13); + w.keys(Object.keys(v), KEYS113); + w.str(v.callee); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "callValue": { + w.length(14); + w.keys(Object.keys(v), KEYS114); + encIrExpr(w, v.callee); + if (v.receiver === undefined) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.receiver); + } + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "caughtCheck": { + w.length(15); + w.keys(Object.keys(v), KEYS115); + encIrExpr(w, v.value); + w.str(v.className); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "caughtNarrow": { + w.length(16); + w.keys(Object.keys(v), KEYS116); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "caughtTest": { + w.length(17); + w.keys(Object.keys(v), KEYS117); + encIrExpr(w, v.value); + w.str(v.test); + if (v.className === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.className); + } + if (v.negated === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.negated); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "caughtToDyn": { + w.length(18); + w.keys(Object.keys(v), KEYS118); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "chainRecv": { + w.length(19); + w.keys(Object.keys(v), KEYS119); + w.str(v.id); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "classRef": { + w.length(20); + w.keys(Object.keys(v), KEYS120); + w.str(v.className); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + if (v.captures === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.captures); + } + return; + } + case "closure": { + w.length(21); + w.keys(Object.keys(v), KEYS121); + w.str(v.fnName); + encT8(w, v.captures); + if (v.adapts === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.adapts); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "downcast": { + w.length(22); + w.keys(Object.keys(v), KEYS122); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynArrLit": { + w.length(23); + w.keys(Object.keys(v), KEYS123); + encT64(w, v.elems); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynCall": { + w.length(24); + w.keys(Object.keys(v), KEYS124); + encIrExpr(w, v.callee); + if (v.receiver === undefined) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.receiver); + } + w.str(v.calleeName); + if (v.calleeNameValue === undefined) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.calleeNameValue); + } + encT64(w, v.args); + if (v.spreads === undefined) w.byte(0); + else { + w.byte(1); + encT66(w, v.spreads); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynCheck": { + w.length(25); + w.keys(Object.keys(v), KEYS125); + encIrExpr(w, v.value); + if (v.preserveRefs === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.preserveRefs); + } + if (v.adopt === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.adopt); + } + if (v.exact === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.exact); + } + if (v.inferredSite === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.inferredSite); + } + if (v.origin === undefined) w.byte(0); + else { + w.byte(1); + w.num(v.origin); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynDestrCheck": { + w.length(26); + w.keys(Object.keys(v), KEYS126); + encIrExpr(w, v.value); + w.str(v.spelling); + if (v.firstProp === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.firstProp); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynFrom": { + w.length(27); + w.keys(Object.keys(v), KEYS127); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + if (v.liveRef === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.liveRef); + } + if (v.fnName === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.fnName); + } + if (v.fnLength === undefined) w.byte(0); + else { + w.byte(1); + w.num(v.fnLength); + } + return; + } + case "dynHasKey": { + w.length(28); + w.keys(Object.keys(v), KEYS128); + w.str(v.key); + if (v.negated === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.negated); + } + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynInvoke": { + w.length(29); + w.keys(Object.keys(v), KEYS129); + encIrExpr(w, v.recv); + w.str(v.method); + w.str(v.calleeName); + if (v.calleeNameValue === undefined) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.calleeNameValue); + } + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynIterN": { + w.length(30); + w.keys(Object.keys(v), KEYS130); + encIrExpr(w, v.value); + w.num(v.count); + if (v.notIterableMessage === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.notIterableMessage); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynKeyGet": { + w.length(31); + w.keys(Object.keys(v), KEYS131); + encIrExpr(w, v.key); + if (v.optional === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.optional); + } + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynObjLit": { + w.length(32); + w.keys(Object.keys(v), KEYS132); + if (v.fields === undefined) w.byte(0); + else { + w.byte(1); + encT67(w, v.fields); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynScalarEq": { + w.length(33); + w.keys(Object.keys(v), KEYS133); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + if (v.negated === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.negated); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "dynTest": { + w.length(34); + w.keys(Object.keys(v), KEYS134); + w.str(v.test); + if (v.bytesElem === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.bytesElem); + } + if (v.negated === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.negated); + } + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "ffiCall": { + w.length(35); + w.keys(Object.keys(v), KEYS135); + w.str(v.import); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "fieldAbsent": { + w.length(36); + w.keys(Object.keys(v), KEYS136); + w.str(v.unionId); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "fieldGet": { + w.length(37); + w.keys(Object.keys(v), KEYS137); + encIrExpr(w, v.obj); + w.str(v.className); + w.str(v.field); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "fieldIncDec": { + w.length(38); + w.keys(Object.keys(v), KEYS138); + w.str(v.op); + w.bool(v.prefix); + encIrExpr(w, v.obj); + w.str(v.className); + w.str(v.field); + w.bool(v.fieldDyn); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "genResume": { + w.length(39); + w.keys(Object.keys(v), KEYS139); + w.str(v.mode); + encIrExpr(w, v.gen); + if (v.arg === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.arg); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "incDec": { + w.length(40); + w.keys(Object.keys(v), KEYS140); + w.str(v.op); + w.bool(v.prefix); + w.str(v.localId); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "instanceOf": { + w.length(41); + w.keys(Object.keys(v), KEYS141); + encIrExpr(w, v.value); + w.str(v.className); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "instanceOfValue": { + w.length(42); + w.keys(Object.keys(v), KEYS142); + encIrExpr(w, v.value); + encIrExpr(w, v.classValue); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "intrinsic": { + w.length(43); + w.keys(Object.keys(v), KEYS143); + w.str(v.name); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "jsonStringify": { + w.length(44); + w.keys(Object.keys(v), KEYS144); + encIrExpr(w, v.value); + if (v.indent === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.indent); + } + if (v.circular === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.circular); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "libCall": { + w.length(45); + w.keys(Object.keys(v), KEYS145); + w.str(v.fn); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + if (v.prototypeIdentityOnly === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.prototypeIdentityOnly); + } + if (v.realTimer === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.realTimer); + } + if (v.assertOptions === undefined) w.byte(0); + else { + w.byte(1); + encT68(w, v.assertOptions); + } + return; + } + case "logical": { + w.length(46); + w.keys(Object.keys(v), KEYS146); + w.str(v.op); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "mapIntrinsic": { + w.length(47); + w.keys(Object.keys(v), KEYS147); + w.str(v.method); + encIrExpr(w, v.receiver); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "mapNew": { + w.length(48); + w.keys(Object.keys(v), KEYS148); + if (v.seed === undefined) w.byte(0); + else { + w.byte(1); + encT69(w, v.seed); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "moduleNsRef": { + w.length(49); + w.keys(Object.keys(v), KEYS149); + w.str(v.moduleId); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "new": { + w.length(50); + w.keys(Object.keys(v), KEYS150); + w.str(v.className); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "newPromise": { + w.length(51); + w.keys(Object.keys(v), KEYS151); + encIrExpr(w, v.executor); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "newValue": { + w.length(52); + w.keys(Object.keys(v), KEYS152); + encIrExpr(w, v.callee); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "nullish": { + w.length(53); + w.keys(Object.keys(v), KEYS153); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "numLit": { + w.length(54); + w.keys(Object.keys(v), KEYS154); + w.num(v.value); + if (v.spelling === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.spelling); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "optChain": { + w.length(55); + w.keys(Object.keys(v), KEYS155); + w.str(v.id); + encIrExpr(w, v.receiver); + encIrExpr(w, v.body); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "orDefault": { + w.length(56); + w.keys(Object.keys(v), KEYS156); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + if (v.retag === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.retag); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "promiseVoidWiden": { + w.length(57); + w.keys(Object.keys(v), KEYS157); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "promiseWithResolvers": { + w.length(58); + w.keys(Object.keys(v), KEYS158); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordClone": { + w.length(59); + w.keys(Object.keys(v), KEYS159); + encIrExpr(w, v.source); + encT70(w, v.overrides); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordGet": { + w.length(60); + w.keys(Object.keys(v), KEYS160); + encIrExpr(w, v.obj); + w.str(v.shapeId); + w.str(v.field); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordHas": { + w.length(61); + w.keys(Object.keys(v), KEYS161); + encIrExpr(w, v.obj); + w.str(v.shapeId); + w.str(v.field); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordKeyGet": { + w.length(62); + w.keys(Object.keys(v), KEYS162); + encIrExpr(w, v.obj); + w.str(v.shapeId); + encIrExpr(w, v.key); + if (v.overflowOnly === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.overflowOnly); + } + if (v.missNaN === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.missNaN); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordLit": { + w.length(63); + w.keys(Object.keys(v), KEYS163); + encT71(w, v.fields); + if (v.nullProto === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.nullProto); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordNullProto": { + w.length(64); + w.keys(Object.keys(v), KEYS164); + encIrExpr(w, v.obj); + w.str(v.shapeId); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordOvfHas": { + w.length(65); + w.keys(Object.keys(v), KEYS165); + encIrExpr(w, v.obj); + w.str(v.shapeId); + encIrExpr(w, v.key); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "recordOvfKeys": { + w.length(66); + w.keys(Object.keys(v), KEYS166); + encIrExpr(w, v.obj); + w.str(v.shapeId); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "regexIntrinsic": { + w.length(67); + w.keys(Object.keys(v), KEYS167); + w.str(v.method); + encIrExpr(w, v.receiver); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "regexLit": { + w.length(68); + w.keys(Object.keys(v), KEYS168); + w.str(v.pattern); + w.str(v.flags); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "selfRef": { + w.length(69); + w.keys(Object.keys(v), KEYS169); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "seqExpr": { + w.length(70); + w.keys(Object.keys(v), KEYS170); + encT19(w, v.stmts); + encIrExpr(w, v.result); + if (v.generatorDelegate === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.generatorDelegate); + } + if (v.diverges === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.diverges); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "setIntrinsic": { + w.length(71); + w.keys(Object.keys(v), KEYS171); + w.str(v.method); + encIrExpr(w, v.receiver); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "setNew": { + w.length(72); + w.keys(Object.keys(v), KEYS172); + if (v.seed === undefined) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.seed); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "strCmp": { + w.length(73); + w.keys(Object.keys(v), KEYS173); + w.str(v.op); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "strConcat": { + w.length(74); + w.keys(Object.keys(v), KEYS174); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "strEq": { + w.length(75); + w.keys(Object.keys(v), KEYS175); + w.bool(v.negated); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "strIntrinsic": { + w.length(76); + w.keys(Object.keys(v), KEYS176); + w.str(v.method); + encIrExpr(w, v.receiver); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "strLit": { + w.length(77); + w.keys(Object.keys(v), KEYS177); + w.str(v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "templateStrings": { + w.length(78); + w.keys(Object.keys(v), KEYS178); + w.str(v.key); + encT8(w, v.cooked); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "ternary": { + w.length(79); + w.keys(Object.keys(v), KEYS179); + encIrExpr(w, v.cond); + encIrExpr(w, v.then); + encIrExpr(w, v.else_); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "toBool": { + w.length(80); + w.keys(Object.keys(v), KEYS180); + encIrExpr(w, v.operand); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "toString": { + w.length(81); + w.keys(Object.keys(v), KEYS181); + encIrExpr(w, v.operand); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unary": { + w.length(82); + w.keys(Object.keys(v), KEYS182); + w.str(v.op); + encIrExpr(w, v.operand); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionDisc": { + w.length(83); + w.keys(Object.keys(v), KEYS183); + w.str(v.unionId); + w.str(v.field); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionEq": { + w.length(84); + w.keys(Object.keys(v), KEYS184); + w.str(v.unionId); + w.bool(v.negated); + w.bool(v.sameValue); + encIrExpr(w, v.left); + encIrExpr(w, v.right); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionFuncEq": { + w.length(85); + w.keys(Object.keys(v), KEYS185); + w.str(v.unionId); + w.num(v.tag); + encIrExpr(w, v.union); + encIrExpr(w, v.func); + w.bool(v.negated); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionIsTag": { + w.length(86); + w.keys(Object.keys(v), KEYS186); + w.str(v.unionId); + w.num(v.tag); + w.bool(v.negated); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionKeyGet": { + w.length(87); + w.keys(Object.keys(v), KEYS187); + w.str(v.unionId); + encIrExpr(w, v.key); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionNarrow": { + w.length(88); + w.keys(Object.keys(v), KEYS188); + w.str(v.unionId); + w.num(v.tag); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionRetype": { + w.length(89); + w.keys(Object.keys(v), KEYS189); + w.str(v.fromId); + w.str(v.unionId); + encT65(w, v.tags); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unionWrap": { + w.length(90); + w.keys(Object.keys(v), KEYS190); + w.str(v.unionId); + w.num(v.tag); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "unitLit": { + w.length(91); + w.keys(Object.keys(v), KEYS191); + w.str(v.unit); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "upcast": { + w.length(92); + w.keys(Object.keys(v), KEYS192); + encIrExpr(w, v.value); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "varRef": { + w.length(93); + w.keys(Object.keys(v), KEYS193); + w.str(v.localId); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + case "virtualCall": { + w.length(94); + w.keys(Object.keys(v), KEYS194); + w.str(v.className); + w.str(v.method); + encT64(w, v.args); + encIrType(w, v.type); + encSrcLoc(w, v.loc); + if (v.targets === undefined) w.byte(0); + else { + w.byte(1); + encT8(w, v.targets); + } + return; + } + case "yieldExpr": { + w.length(95); + w.keys(Object.keys(v), KEYS195); + if (v.value === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.value); + } + if (v.awaited === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.awaited); + } + if (v.delegated === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.delegated); + } + if (v.captureCompletion === undefined) w.byte(0); + else { + w.byte(1); + encT72(w, v.captureCompletion); + } + encIrType(w, v.type); + encSrcLoc(w, v.loc); + return; + } + } + w.refuse("IrExpr kind " + String((v as { kind: unknown }).kind)); +} +function decIrExpr(r: IrReader): IrExpr { + switch (r.length()) { + case 0: { + const f0 = (r.str() as NonNullable["method"]>); + const f1 = decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "arrIntrinsic", method: f0, receiver: f1, args: f2, type: f3, loc: f4 }; + return o; + } + case 1: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "arrayGet", arr: f0, index: f1, type: f2, loc: f3 }; + return o; + } + case 2: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "arrayHas", arr: f0, index: f1, type: f2, loc: f3 }; + return o; + } + case 3: { + const f0 = decT64(r); + const f1 = r.tag(2, "IrFunction.body[].arr.spreads") === 0 ? undefined : decT65(r); + const f2 = r.tag(2, "IrFunction.body[].arr.capacity") === 0 ? undefined : r.num(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "arrayLit", elems: f0, ...(f1 !== undefined ? { spreads: f1 } : {}), ...(f2 !== undefined ? { capacity: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 4: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "arrayNewLen", length: f0, type: f1, loc: f2 }; + return o; + } + case 5: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "arrayState", arr: f0, index: f1, type: f2, loc: f3 }; + return o; + } + case 6: { + const f0 = r.str(); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "assignExpr", localId: f0, value: f1, type: f2, loc: f3 }; + return o; + } + case 7: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = r.tag(2, "IrFunction.body[].arr.adopt") === 0 ? undefined : (r.bool() as NonNullable["adopt"]>); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "awaitExpr", value: f0, type: f1, ...(f2 !== undefined ? { adopt: f2 } : {}), loc: f3 }; + return o; + } + case 8: { + const f0 = decIrExpr(r); + const f1 = r.num(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "awaitUnionExpr", value: f0, promiseTag: f1, type: f2, loc: f3 }; + return o; + } + case 9: { + const f0 = (r.str() as NonNullable["op"]>); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "bin", op: f0, left: f1, right: f2, type: f3, loc: f4 }; + return o; + } + case 10: { + const f0 = r.bool(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "boolLit", value: f0, type: f1, loc: f2 }; + return o; + } + case 11: { + const f0 = (r.str() as NonNullable["method"]>); + const f1 = decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const f5 = r.tag(2, "IrFunction.body[].arr.invalidNaN") === 0 ? undefined : (r.bool() as NonNullable["invalidNaN"]>); + const o: Extract = { kind: "bytesIntrinsic", method: f0, receiver: f1, args: f2, type: f3, loc: f4, ...(f5 !== undefined ? { invalidNaN: f5 } : {}) }; + return o; + } + case 12: { + const f0 = r.tag(2, "IrFunction.body[].arr.source") === 0 ? null : decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.from") === 0 ? undefined : (r.bool() as NonNullable["from"]>); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "bytesNew", source: f0, ...(f1 !== undefined ? { from: f1 } : {}), type: f2, loc: f3 }; + return o; + } + case 13: { + const f0 = r.str(); + const f1 = decT64(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "call", callee: f0, args: f1, type: f2, loc: f3 }; + return o; + } + case 14: { + const f0 = decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.receiver") === 0 ? undefined : decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "callValue", callee: f0, ...(f1 !== undefined ? { receiver: f1 } : {}), args: f2, type: f3, loc: f4 }; + return o; + } + case 15: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "caughtCheck", value: f0, className: f1, type: f2, loc: f3 }; + return o; + } + case 16: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "caughtNarrow", value: f0, type: f1, loc: f2 }; + return o; + } + case 17: { + const f0 = decIrExpr(r); + const f1 = (r.str() as NonNullable["test"]>); + const f2 = r.tag(2, "IrFunction.body[].arr.className") === 0 ? undefined : r.str(); + const f3 = r.tag(2, "IrFunction.body[].arr.negated") === 0 ? undefined : r.bool(); + const f4 = decIrType(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "caughtTest", value: f0, test: f1, ...(f2 !== undefined ? { className: f2 } : {}), ...(f3 !== undefined ? { negated: f3 } : {}), type: f4, loc: f5 }; + return o; + } + case 18: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "caughtToDyn", value: f0, type: f1, loc: f2 }; + return o; + } + case 19: { + const f0 = r.str(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "chainRecv", id: f0, type: f1, loc: f2 }; + return o; + } + case 20: { + const f0 = r.str(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const f3 = r.tag(2, "IrFunction.body[].arr.captures") === 0 ? undefined : decT8(r); + const o: Extract = { kind: "classRef", className: f0, type: f1, loc: f2, ...(f3 !== undefined ? { captures: f3 } : {}) }; + return o; + } + case 21: { + const f0 = r.str(); + const f1 = decT8(r); + const f2 = r.tag(2, "IrFunction.body[].arr.adapts") === 0 ? undefined : (r.bool() as NonNullable["adapts"]>); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "closure", fnName: f0, captures: f1, ...(f2 !== undefined ? { adapts: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 22: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "downcast", value: f0, type: f1, loc: f2 }; + return o; + } + case 23: { + const f0 = decT64(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "dynArrLit", elems: f0, type: f1, loc: f2 }; + return o; + } + case 24: { + const f0 = decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.receiver") === 0 ? undefined : decIrExpr(r); + const f2 = r.str(); + const f3 = r.tag(2, "IrFunction.body[].arr.calleeNameValue") === 0 ? undefined : decIrExpr(r); + const f4 = decT64(r); + const f5 = r.tag(2, "IrFunction.body[].arr.spreads") === 0 ? undefined : decT66(r); + const f6 = decIrType(r); + const f7 = decSrcLoc(r); + const o: Extract = { kind: "dynCall", callee: f0, ...(f1 !== undefined ? { receiver: f1 } : {}), calleeName: f2, ...(f3 !== undefined ? { calleeNameValue: f3 } : {}), args: f4, ...(f5 !== undefined ? { spreads: f5 } : {}), type: f6, loc: f7 }; + return o; + } + case 25: { + const f0 = decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.preserveRefs") === 0 ? undefined : (r.bool() as NonNullable["preserveRefs"]>); + const f2 = r.tag(2, "IrFunction.body[].arr.adopt") === 0 ? undefined : (r.bool() as NonNullable["adopt"]>); + const f3 = r.tag(2, "IrFunction.body[].arr.exact") === 0 ? undefined : (r.bool() as NonNullable["exact"]>); + const f4 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str(); + const f5 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num(); + const f6 = decIrType(r); + const f7 = decSrcLoc(r); + const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { inferredSite: f4 } : {}), ...(f5 !== undefined ? { origin: f5 } : {}), type: f6, loc: f7 }; + return o; + } + case 26: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.tag(2, "IrFunction.body[].arr.firstProp") === 0 ? undefined : r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "dynDestrCheck", value: f0, spelling: f1, ...(f2 !== undefined ? { firstProp: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 27: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const f3 = r.tag(2, "IrFunction.body[].arr.liveRef") === 0 ? undefined : (r.bool() as NonNullable["liveRef"]>); + const f4 = r.tag(2, "IrFunction.body[].arr.fnName") === 0 ? undefined : r.str(); + const f5 = r.tag(2, "IrFunction.body[].arr.fnLength") === 0 ? undefined : r.num(); + const o: Extract = { kind: "dynFrom", value: f0, type: f1, loc: f2, ...(f3 !== undefined ? { liveRef: f3 } : {}), ...(f4 !== undefined ? { fnName: f4 } : {}), ...(f5 !== undefined ? { fnLength: f5 } : {}) }; + return o; + } + case 28: { + const f0 = r.str(); + const f1 = r.tag(2, "IrFunction.body[].arr.negated") === 0 ? undefined : (r.bool() as NonNullable["negated"]>); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "dynHasKey", key: f0, ...(f1 !== undefined ? { negated: f1 } : {}), value: f2, type: f3, loc: f4 }; + return o; + } + case 29: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.str(); + const f3 = r.tag(2, "IrFunction.body[].arr.calleeNameValue") === 0 ? undefined : decIrExpr(r); + const f4 = decT64(r); + const f5 = decIrType(r); + const f6 = decSrcLoc(r); + const o: Extract = { kind: "dynInvoke", recv: f0, method: f1, calleeName: f2, ...(f3 !== undefined ? { calleeNameValue: f3 } : {}), args: f4, type: f5, loc: f6 }; + return o; + } + case 30: { + const f0 = decIrExpr(r); + const f1 = r.num(); + const f2 = r.tag(2, "IrFunction.body[].arr.notIterableMessage") === 0 ? undefined : r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "dynIterN", value: f0, count: f1, ...(f2 !== undefined ? { notIterableMessage: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 31: { + const f0 = decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.optional") === 0 ? undefined : (r.bool() as NonNullable["optional"]>); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "dynKeyGet", key: f0, ...(f1 !== undefined ? { optional: f1 } : {}), value: f2, type: f3, loc: f4 }; + return o; + } + case 32: { + const f0 = r.tag(2, "IrFunction.body[].arr.fields") === 0 ? undefined : decT67(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "dynObjLit", ...(f0 !== undefined ? { fields: f0 } : {}), type: f1, loc: f2 }; + return o; + } + case 33: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].arr.negated") === 0 ? undefined : (r.bool() as NonNullable["negated"]>); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "dynScalarEq", left: f0, right: f1, ...(f2 !== undefined ? { negated: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 34: { + const f0 = (r.str() as NonNullable["test"]>); + const f1 = r.tag(2, "IrFunction.body[].arr.bytesElem") === 0 ? undefined : (r.str() as NonNullable["bytesElem"]>); + const f2 = r.tag(2, "IrFunction.body[].arr.negated") === 0 ? undefined : (r.bool() as NonNullable["negated"]>); + const f3 = decIrExpr(r); + const f4 = decIrType(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "dynTest", test: f0, ...(f1 !== undefined ? { bytesElem: f1 } : {}), ...(f2 !== undefined ? { negated: f2 } : {}), value: f3, type: f4, loc: f5 }; + return o; + } + case 35: { + const f0 = r.str(); + const f1 = decT64(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "ffiCall", import: f0, args: f1, type: f2, loc: f3 }; + return o; + } + case 36: { + const f0 = r.str(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "fieldAbsent", unionId: f0, type: f1, loc: f2 }; + return o; + } + case 37: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "fieldGet", obj: f0, className: f1, field: f2, type: f3, loc: f4 }; + return o; + } + case 38: { + const f0 = (r.str() as NonNullable["op"]>); + const f1 = r.bool(); + const f2 = decIrExpr(r); + const f3 = r.str(); + const f4 = r.str(); + const f5 = r.bool(); + const f6 = decIrType(r); + const f7 = decSrcLoc(r); + const o: Extract = { kind: "fieldIncDec", op: f0, prefix: f1, obj: f2, className: f3, field: f4, fieldDyn: f5, type: f6, loc: f7 }; + return o; + } + case 39: { + const f0 = (r.str() as NonNullable["mode"]>); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].arr.arg") === 0 ? null : decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "genResume", mode: f0, gen: f1, arg: f2, type: f3, loc: f4 }; + return o; + } + case 40: { + const f0 = (r.str() as NonNullable["op"]>); + const f1 = r.bool(); + const f2 = r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "incDec", op: f0, prefix: f1, localId: f2, type: f3, loc: f4 }; + return o; + } + case 41: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "instanceOf", value: f0, className: f1, type: f2, loc: f3 }; + return o; + } + case 42: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "instanceOfValue", value: f0, classValue: f1, type: f2, loc: f3 }; + return o; + } + case 43: { + const f0 = (r.str() as NonNullable["name"]>); + const f1 = decT64(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "intrinsic", name: f0, args: f1, type: f2, loc: f3 }; + return o; + } + case 44: { + const f0 = decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.indent") === 0 ? undefined : r.str(); + const f2 = r.tag(2, "IrFunction.body[].arr.circular") === 0 ? undefined : (r.bool() as NonNullable["circular"]>); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "jsonStringify", value: f0, ...(f1 !== undefined ? { indent: f1 } : {}), ...(f2 !== undefined ? { circular: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 45: { + const f0 = (r.str() as NonNullable["fn"]>); + const f1 = decT64(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const f4 = r.tag(2, "IrFunction.body[].arr.prototypeIdentityOnly") === 0 ? undefined : (r.bool() as NonNullable["prototypeIdentityOnly"]>); + const f5 = r.tag(2, "IrFunction.body[].arr.realTimer") === 0 ? undefined : (r.bool() as NonNullable["realTimer"]>); + const f6 = r.tag(2, "IrFunction.body[].arr.assertOptions") === 0 ? undefined : decT68(r); + const o: Extract = { kind: "libCall", fn: f0, args: f1, type: f2, loc: f3, ...(f4 !== undefined ? { prototypeIdentityOnly: f4 } : {}), ...(f5 !== undefined ? { realTimer: f5 } : {}), ...(f6 !== undefined ? { assertOptions: f6 } : {}) }; + return o; + } + case 46: { + const f0 = (r.str() as NonNullable["op"]>); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "logical", op: f0, left: f1, right: f2, type: f3, loc: f4 }; + return o; + } + case 47: { + const f0 = (r.str() as NonNullable["method"]>); + const f1 = decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "mapIntrinsic", method: f0, receiver: f1, args: f2, type: f3, loc: f4 }; + return o; + } + case 48: { + const f0 = r.tag(2, "IrFunction.body[].arr.seed") === 0 ? undefined : decT69(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "mapNew", ...(f0 !== undefined ? { seed: f0 } : {}), type: f1, loc: f2 }; + return o; + } + case 49: { + const f0 = r.str(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "moduleNsRef", moduleId: f0, type: f1, loc: f2 }; + return o; + } + case 50: { + const f0 = r.str(); + const f1 = decT64(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "new", className: f0, args: f1, type: f2, loc: f3 }; + return o; + } + case 51: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "newPromise", executor: f0, type: f1, loc: f2 }; + return o; + } + case 52: { + const f0 = decIrExpr(r); + const f1 = decT64(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "newValue", callee: f0, args: f1, type: f2, loc: f3 }; + return o; + } + case 53: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "nullish", left: f0, right: f1, type: f2, loc: f3 }; + return o; + } + case 54: { + const f0 = r.num(); + const f1 = r.tag(2, "IrFunction.body[].arr.spelling") === 0 ? undefined : r.str(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "numLit", value: f0, ...(f1 !== undefined ? { spelling: f1 } : {}), type: f2, loc: f3 }; + return o; + } + case 55: { + const f0 = r.str(); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "optChain", id: f0, receiver: f1, body: f2, type: f3, loc: f4 }; + return o; + } + case 56: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].arr.retag") === 0 ? undefined : r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "orDefault", left: f0, right: f1, ...(f2 !== undefined ? { retag: f2 } : {}), type: f3, loc: f4 }; + return o; + } + case 57: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "promiseVoidWiden", value: f0, type: f1, loc: f2 }; + return o; + } + case 58: { + const f0 = decIrType(r); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "promiseWithResolvers", type: f0, loc: f1 }; + return o; + } + case 59: { + const f0 = decIrExpr(r); + const f1 = decT70(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "recordClone", source: f0, overrides: f1, type: f2, loc: f3 }; + return o; + } + case 60: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "recordGet", obj: f0, shapeId: f1, field: f2, type: f3, loc: f4 }; + return o; + } + case 61: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = r.str(); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "recordHas", obj: f0, shapeId: f1, field: f2, type: f3, loc: f4 }; + return o; + } + case 62: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrExpr(r); + const f3 = r.tag(2, "IrFunction.body[].arr.overflowOnly") === 0 ? undefined : (r.bool() as NonNullable["overflowOnly"]>); + const f4 = r.tag(2, "IrFunction.body[].arr.missNaN") === 0 ? undefined : (r.bool() as NonNullable["missNaN"]>); + const f5 = decIrType(r); + const f6 = decSrcLoc(r); + const o: Extract = { kind: "recordKeyGet", obj: f0, shapeId: f1, key: f2, ...(f3 !== undefined ? { overflowOnly: f3 } : {}), ...(f4 !== undefined ? { missNaN: f4 } : {}), type: f5, loc: f6 }; + return o; + } + case 63: { + const f0 = decT71(r); + const f1 = r.tag(2, "IrFunction.body[].arr.nullProto") === 0 ? undefined : (r.bool() as NonNullable["nullProto"]>); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "recordLit", fields: f0, ...(f1 !== undefined ? { nullProto: f1 } : {}), type: f2, loc: f3 }; + return o; + } + case 64: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "recordNullProto", obj: f0, shapeId: f1, type: f2, loc: f3 }; + return o; + } + case 65: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "recordOvfHas", obj: f0, shapeId: f1, key: f2, type: f3, loc: f4 }; + return o; + } + case 66: { + const f0 = decIrExpr(r); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "recordOvfKeys", obj: f0, shapeId: f1, type: f2, loc: f3 }; + return o; + } + case 67: { + const f0 = (r.str() as NonNullable["method"]>); + const f1 = decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "regexIntrinsic", method: f0, receiver: f1, args: f2, type: f3, loc: f4 }; + return o; + } + case 68: { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "regexLit", pattern: f0, flags: f1, type: f2, loc: f3 }; + return o; + } + case 69: { + const f0 = decIrType(r); + const f1 = decSrcLoc(r); + const o: Extract = { kind: "selfRef", type: f0, loc: f1 }; + return o; + } + case 70: { + const f0 = decT19(r); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].arr.generatorDelegate") === 0 ? undefined : (r.bool() as NonNullable["generatorDelegate"]>); + const f3 = r.tag(2, "IrFunction.body[].arr.diverges") === 0 ? undefined : (r.bool() as NonNullable["diverges"]>); + const f4 = decIrType(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "seqExpr", stmts: f0, result: f1, ...(f2 !== undefined ? { generatorDelegate: f2 } : {}), ...(f3 !== undefined ? { diverges: f3 } : {}), type: f4, loc: f5 }; + return o; + } + case 71: { + const f0 = (r.str() as NonNullable["method"]>); + const f1 = decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "setIntrinsic", method: f0, receiver: f1, args: f2, type: f3, loc: f4 }; + return o; + } + case 72: { + const f0 = r.tag(2, "IrFunction.body[].arr.seed") === 0 ? undefined : decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "setNew", ...(f0 !== undefined ? { seed: f0 } : {}), type: f1, loc: f2 }; + return o; + } + case 73: { + const f0 = (r.str() as NonNullable["op"]>); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "strCmp", op: f0, left: f1, right: f2, type: f3, loc: f4 }; + return o; + } + case 74: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "strConcat", left: f0, right: f1, type: f2, loc: f3 }; + return o; + } + case 75: { + const f0 = r.bool(); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "strEq", negated: f0, left: f1, right: f2, type: f3, loc: f4 }; + return o; + } + case 76: { + const f0 = (r.str() as NonNullable["method"]>); + const f1 = decIrExpr(r); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "strIntrinsic", method: f0, receiver: f1, args: f2, type: f3, loc: f4 }; + return o; + } + case 77: { + const f0 = r.str(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "strLit", value: f0, type: f1, loc: f2 }; + return o; + } + case 78: { + const f0 = r.str(); + const f1 = decT8(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "templateStrings", key: f0, cooked: f1, type: f2, loc: f3 }; + return o; + } + case 79: { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "ternary", cond: f0, then: f1, else_: f2, type: f3, loc: f4 }; + return o; + } + case 80: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "toBool", operand: f0, type: f1, loc: f2 }; + return o; + } + case 81: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "toString", operand: f0, type: f1, loc: f2 }; + return o; + } + case 82: { + const f0 = (r.str() as NonNullable["op"]>); + const f1 = decIrExpr(r); + const f2 = decIrType(r); + const f3 = decSrcLoc(r); + const o: Extract = { kind: "unary", op: f0, operand: f1, type: f2, loc: f3 }; + return o; + } + case 83: { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "unionDisc", unionId: f0, field: f1, value: f2, type: f3, loc: f4 }; + return o; + } + case 84: { + const f0 = r.str(); + const f1 = r.bool(); + const f2 = r.bool(); + const f3 = decIrExpr(r); + const f4 = decIrExpr(r); + const f5 = decIrType(r); + const f6 = decSrcLoc(r); + const o: Extract = { kind: "unionEq", unionId: f0, negated: f1, sameValue: f2, left: f3, right: f4, type: f5, loc: f6 }; + return o; + } + case 85: { + const f0 = r.str(); + const f1 = r.num(); + const f2 = decIrExpr(r); + const f3 = decIrExpr(r); + const f4 = r.bool(); + const f5 = decIrType(r); + const f6 = decSrcLoc(r); + const o: Extract = { kind: "unionFuncEq", unionId: f0, tag: f1, union: f2, func: f3, negated: f4, type: f5, loc: f6 }; + return o; + } + case 86: { + const f0 = r.str(); + const f1 = r.num(); + const f2 = r.bool(); + const f3 = decIrExpr(r); + const f4 = decIrType(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "unionIsTag", unionId: f0, tag: f1, negated: f2, value: f3, type: f4, loc: f5 }; + return o; + } + case 87: { + const f0 = r.str(); + const f1 = decIrExpr(r); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "unionKeyGet", unionId: f0, key: f1, value: f2, type: f3, loc: f4 }; + return o; + } + case 88: { + const f0 = r.str(); + const f1 = r.num(); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "unionNarrow", unionId: f0, tag: f1, value: f2, type: f3, loc: f4 }; + return o; + } + case 89: { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decT65(r); + const f3 = decIrExpr(r); + const f4 = decIrType(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "unionRetype", fromId: f0, unionId: f1, tags: f2, value: f3, type: f4, loc: f5 }; + return o; + } + case 90: { + const f0 = r.str(); + const f1 = r.num(); + const f2 = decIrExpr(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const o: Extract = { kind: "unionWrap", unionId: f0, tag: f1, value: f2, type: f3, loc: f4 }; + return o; + } + case 91: { + const f0 = (r.str() as NonNullable["unit"]>); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "unitLit", unit: f0, type: f1, loc: f2 }; + return o; + } + case 92: { + const f0 = decIrExpr(r); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "upcast", value: f0, type: f1, loc: f2 }; + return o; + } + case 93: { + const f0 = r.str(); + const f1 = decIrType(r); + const f2 = decSrcLoc(r); + const o: Extract = { kind: "varRef", localId: f0, type: f1, loc: f2 }; + return o; + } + case 94: { + const f0 = r.str(); + const f1 = r.str(); + const f2 = decT64(r); + const f3 = decIrType(r); + const f4 = decSrcLoc(r); + const f5 = r.tag(2, "IrFunction.body[].arr.targets") === 0 ? undefined : decT8(r); + const o: Extract = { kind: "virtualCall", className: f0, method: f1, args: f2, type: f3, loc: f4, ...(f5 !== undefined ? { targets: f5 } : {}) }; + return o; + } + case 95: { + const f0 = r.tag(2, "IrFunction.body[].arr.value") === 0 ? null : decIrExpr(r); + const f1 = r.tag(2, "IrFunction.body[].arr.awaited") === 0 ? undefined : (r.bool() as NonNullable["awaited"]>); + const f2 = r.tag(2, "IrFunction.body[].arr.delegated") === 0 ? undefined : (r.bool() as NonNullable["delegated"]>); + const f3 = r.tag(2, "IrFunction.body[].arr.captureCompletion") === 0 ? undefined : decT72(r); + const f4 = decIrType(r); + const f5 = decSrcLoc(r); + const o: Extract = { kind: "yieldExpr", value: f0, ...(f1 !== undefined ? { awaited: f1 } : {}), ...(f2 !== undefined ? { delegated: f2 } : {}), ...(f3 !== undefined ? { captureCompletion: f3 } : {}), type: f4, loc: f5 }; + return o; + } + } + throw new Error("IR codec: invalid IrExpr kind"); +} + +function encT54(w: IrWriter, v: NonNullable["cases"]>): void { + w.length(v.length); + for (const item of v) { + encT73(w, item); + } +} +function decT54(r: IrReader): NonNullable["cases"]> { + const length = r.length(); + const out: NonNullable["cases"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT73(r)); + return out; +} + +function encT55(w: IrWriter, v: NonNullable[number]>): void { + if (typeof v === "object" && "globalId" in v) { + w.length(0); + encT74(w, v as Extract[number]>, { globalId: unknown }>); + return; + } + if (typeof v === "object" && "wellKnown" in v) { + w.length(1); + encT75(w, v as Extract[number]>, { wellKnown: unknown }>); + return; + } + throw new Error("IR codec: no arm of T55 matches"); +} +function decT55(r: IrReader): NonNullable[number]> { + switch (r.length()) { + case 0: + return decT74(r) as NonNullable[number]>; + case 1: + return decT75(r) as NonNullable[number]>; + } + throw new Error("IR codec: invalid T55 arm"); +} + +function encT56(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS196); + w.str(v.name); + encIrType(w, v.type); +} +function decT56(r: IrReader): NonNullable[number]> { + const f0 = r.str(); + const f1 = decIrType(r); + const o: NonNullable[number]> = { name: f0, type: f1 }; + return o; +} + +function encT57(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS197); + w.str(v.name); + encIrType(w, v.type); +} +function decT57(r: IrReader): NonNullable[number]> { + const f0 = r.str(); + const f1 = decIrType(r); + const o: NonNullable[number]> = { name: f0, type: f1 }; + return o; +} + +function encT58(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + encT76(w, item); + } +} +function decT58(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push(decT76(r)); + return out; +} + +function encT59(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS198); + w.str(v.path); + w.num(v.size); + w.str(v.sha256); + if (v.placeholder === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.placeholder); + } +} +function decT59(r: IrReader): NonNullable[number]> { + const f0 = r.str(); + const f1 = r.num(); + const f2 = r.str(); + const f3 = r.tag(2, "IrModule.nodeIsland.modules.files[].placeholder") === 0 ? undefined : (r.bool() as NonNullable[number]>["placeholder"]>); + const o: NonNullable[number]> = { path: f0, size: f1, sha256: f2, ...(f3 !== undefined ? { placeholder: f3 } : {}) }; + return o; +} + +function encT60(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS199); + w.str(v.path); + w.str(v.target); +} +function decT60(r: IrReader): NonNullable[number]> { + const f0 = r.str(); + const f1 = r.str(); + const o: NonNullable[number]> = { path: f0, target: f1 }; + return o; +} + +function encIrFfiParam(w: IrWriter, v: IrFfiParam): void { + if (typeof v === "string") { + w.length(0); + w.str(v); + return; + } + if (typeof v === "object" && "context" in v) { + w.length(1); + encIrFfiContextParam(w, v as Extract); + return; + } + if (typeof v === "object" && "callback" in v && "id" in (v as { callback: object }).callback) { + w.length(2); + encIrFfiCallbackParam(w, v as Extract); + return; + } + if (typeof v === "object" && "callback" in v && "release" in (v as { callback: object }).callback) { + w.length(3); + encIrFfiReleaseParam(w, v as Extract); + return; + } + throw new Error("IR codec: no arm of IrFfiParam matches"); +} +function decIrFfiParam(r: IrReader): IrFfiParam { + switch (r.length()) { + case 0: + return r.str() as IrFfiParam; + case 1: + return decIrFfiContextParam(r) as IrFfiParam; + case 2: + return decIrFfiCallbackParam(r) as IrFfiParam; + case 3: + return decIrFfiReleaseParam(r) as IrFfiParam; + } + throw new Error("IR codec: invalid IrFfiParam arm"); +} + +function encT62(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + w.str(item); + } +} +function decT62(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push((r.str() as NonNullable[number]>)); + return out; +} + +function encT63(w: IrWriter, v: NonNullable): void { + w.length(v.length); + for (const item of v) { + w.str(item); + } +} +function decT63(r: IrReader): NonNullable { + const length = r.length(); + const out: NonNullable[number][] = []; + for (let i = 0; i < length; i++) out.push((r.str() as NonNullable[number]>)); + return out; +} + +function encT64(w: IrWriter, v: NonNullable["args"]>): void { + w.length(v.length); + for (const item of v) { + encIrExpr(w, item); + } +} +function decT64(r: IrReader): NonNullable["args"]> { + const length = r.length(); + const out: NonNullable["args"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decIrExpr(r)); + return out; +} + +function encT65(w: IrWriter, v: NonNullable["spreads"]>): void { + w.length(v.length); + for (const item of v) { + w.num(item); + } +} +function decT65(r: IrReader): NonNullable["spreads"]> { + const length = r.length(); + const out: NonNullable["spreads"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(r.num()); + return out; +} + +function encT66(w: IrWriter, v: NonNullable["spreads"]>): void { + w.length(v.length); + for (const item of v) { + encT80(w, item); + } +} +function decT66(r: IrReader): NonNullable["spreads"]> { + const length = r.length(); + const out: NonNullable["spreads"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT80(r)); + return out; +} + +function encT67(w: IrWriter, v: NonNullable["fields"]>): void { + w.length(v.length); + for (const item of v) { + encT81(w, item); + } +} +function decT67(r: IrReader): NonNullable["fields"]> { + const length = r.length(); + const out: NonNullable["fields"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT81(r)); + return out; +} + +function encT68(w: IrWriter, v: NonNullable["assertOptions"]>): void { + w.keys(Object.keys(v), KEYS200); + if (v.diff === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.diff); + } + if (v.skipPrototype === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.skipPrototype); + } +} +function decT68(r: IrReader): NonNullable["assertOptions"]> { + const f0 = r.tag(2, "IrFunction.body[].arr.assertOptions.diff") === 0 ? undefined : (r.str() as NonNullable["assertOptions"]>["diff"]>); + const f1 = r.tag(2, "IrFunction.body[].arr.assertOptions.skipPrototype") === 0 ? undefined : (r.bool() as NonNullable["assertOptions"]>["skipPrototype"]>); + const o: NonNullable["assertOptions"]> = { ...(f0 !== undefined ? { diff: f0 } : {}), ...(f1 !== undefined ? { skipPrototype: f1 } : {}) }; + return o; +} + +function encT69(w: IrWriter, v: NonNullable["seed"]>): void { + w.length(v.length); + for (const item of v) { + encT82(w, item); + } +} +function decT69(r: IrReader): NonNullable["seed"]> { + const length = r.length(); + const out: NonNullable["seed"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT82(r)); + return out; +} + +function encT70(w: IrWriter, v: NonNullable["overrides"]>): void { + w.length(v.length); + for (const item of v) { + encT83(w, item); + } +} +function decT70(r: IrReader): NonNullable["overrides"]> { + const length = r.length(); + const out: NonNullable["overrides"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT83(r)); + return out; +} + +function encT71(w: IrWriter, v: NonNullable["fields"]>): void { + w.length(v.length); + for (const item of v) { + encT84(w, item); + } +} +function decT71(r: IrReader): NonNullable["fields"]> { + const length = r.length(); + const out: NonNullable["fields"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT84(r)); + return out; +} + +function encT72(w: IrWriter, v: NonNullable["captureCompletion"]>): void { + w.keys(Object.keys(v), KEYS201); + encIrType(w, v.returnType); +} +function decT72(r: IrReader): NonNullable["captureCompletion"]> { + const f0 = decIrType(r); + const o: NonNullable["captureCompletion"]> = { returnType: f0 }; + return o; +} + +function encT73(w: IrWriter, v: NonNullable["cases"]>[number]>): void { + w.keys(Object.keys(v), KEYS202); + if (v.test === null) w.byte(0); + else { + w.byte(1); + encIrExpr(w, v.test); + } + encT19(w, v.body); +} +function decT73(r: IrReader): NonNullable["cases"]>[number]> { + const f0 = r.tag(2, "IrFunction.body[].cases[].test") === 0 ? null : decIrExpr(r); + const f1 = decT19(r); + const o: NonNullable["cases"]>[number]> = { test: f0, body: f1 }; + return o; +} + +function encT74(w: IrWriter, v: Extract[number]>, { globalId: unknown }>): void { + w.keys(Object.keys(v), KEYS203); + w.str(v.field); + w.str(v.globalId); +} +function decT74(r: IrReader): Extract[number]>, { globalId: unknown }> { + const f0 = r.str(); + const f1 = r.str(); + const o: Extract[number]>, { globalId: unknown }> = { field: f0, globalId: f1 }; + return o; +} + +function encT75(w: IrWriter, v: Extract[number]>, { wellKnown: unknown }>): void { + w.keys(Object.keys(v), KEYS204); + w.str(v.field); + w.str(v.wellKnown); +} +function decT75(r: IrReader): Extract[number]>, { wellKnown: unknown }> { + const f0 = r.str(); + const f1 = r.str(); + const o: Extract[number]>, { wellKnown: unknown }> = { field: f0, wellKnown: f1 }; + return o; +} + +function encT76(w: IrWriter, v: NonNullable[number]>): void { + w.keys(Object.keys(v), KEYS205); + w.num(v.tag); + encT85(w, v.values); +} +function decT76(r: IrReader): NonNullable[number]> { + const f0 = r.num(); + const f1 = decT85(r); + const o: NonNullable[number]> = { tag: f0, values: f1 }; + return o; +} + +function encIrFfiContextParam(w: IrWriter, v: IrFfiContextParam): void { + w.keys(Object.keys(v), KEYS206); + w.str(v.context); +} +function decIrFfiContextParam(r: IrReader): IrFfiContextParam { + const f0 = r.str(); + const o: IrFfiContextParam = { context: f0 }; + return o; +} + +function encIrFfiCallbackParam(w: IrWriter, v: IrFfiCallbackParam): void { + w.keys(Object.keys(v), KEYS207); + encT86(w, v.callback); +} +function decIrFfiCallbackParam(r: IrReader): IrFfiCallbackParam { + const f0 = decT86(r); + const o: IrFfiCallbackParam = { callback: f0 }; + return o; +} + +function encIrFfiReleaseParam(w: IrWriter, v: IrFfiReleaseParam): void { + w.keys(Object.keys(v), KEYS208); + encT87(w, v.callback); +} +function decIrFfiReleaseParam(r: IrReader): IrFfiReleaseParam { + const f0 = decT87(r); + const o: IrFfiReleaseParam = { callback: f0 }; + return o; +} + +function encT80(w: IrWriter, v: NonNullable["spreads"]>[number]>): void { + w.keys(Object.keys(v), KEYS209); + w.num(v.arg); + w.str(v.what); +} +function decT80(r: IrReader): NonNullable["spreads"]>[number]> { + const f0 = r.num(); + const f1 = r.str(); + const o: NonNullable["spreads"]>[number]> = { arg: f0, what: f1 }; + return o; +} + +function encT81(w: IrWriter, v: NonNullable["fields"]>[number]>): void { + w.keys(Object.keys(v), KEYS210); + encIrExpr(w, v.key); + encIrExpr(w, v.value); +} +function decT81(r: IrReader): NonNullable["fields"]>[number]> { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const o: NonNullable["fields"]>[number]> = { key: f0, value: f1 }; + return o; +} + +function encT82(w: IrWriter, v: NonNullable["seed"]>[number]>): void { + w.keys(Object.keys(v), KEYS211); + encIrExpr(w, v.key); + encIrExpr(w, v.value); +} +function decT82(r: IrReader): NonNullable["seed"]>[number]> { + const f0 = decIrExpr(r); + const f1 = decIrExpr(r); + const o: NonNullable["seed"]>[number]> = { key: f0, value: f1 }; + return o; +} + +function encT83(w: IrWriter, v: NonNullable["overrides"]>[number]>): void { + w.keys(Object.keys(v), KEYS212); + w.str(v.name); + encIrExpr(w, v.value); +} +function decT83(r: IrReader): NonNullable["overrides"]>[number]> { + const f0 = r.str(); + const f1 = decIrExpr(r); + const o: NonNullable["overrides"]>[number]> = { name: f0, value: f1 }; + return o; +} + +function encT84(w: IrWriter, v: NonNullable["fields"]>[number]>): void { + w.keys(Object.keys(v), KEYS213); + w.str(v.name); + encIrExpr(w, v.value); + if (v.overflow === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.overflow); + } +} +function decT84(r: IrReader): NonNullable["fields"]>[number]> { + const f0 = r.str(); + const f1 = decIrExpr(r); + const f2 = r.tag(2, "IrFunction.body[].arr.fields[].overflow") === 0 ? undefined : (r.bool() as NonNullable["fields"]>[number]>["overflow"]>); + const o: NonNullable["fields"]>[number]> = { name: f0, value: f1, ...(f2 !== undefined ? { overflow: f2 } : {}) }; + return o; +} + +function encT85(w: IrWriter, v: NonNullable[number]>["values"]>): void { + w.length(v.length); + for (const item of v) { + encT88(w, item); + } +} +function decT85(r: IrReader): NonNullable[number]>["values"]> { + const length = r.length(); + const out: NonNullable[number]>["values"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT88(r)); + return out; +} + +function encT86(w: IrWriter, v: NonNullable): void { + w.keys(Object.keys(v), KEYS214); + w.str(v.id); + encT89(w, v.params); + w.str(v.returns); + w.str(v.lifetime); + w.str(v.invoke); +} +function decT86(r: IrReader): NonNullable { + const f0 = r.str(); + const f1 = decT89(r); + const f2 = (r.str() as NonNullable["returns"]>); + const f3 = (r.str() as NonNullable["lifetime"]>); + const f4 = (r.str() as NonNullable["invoke"]>); + const o: NonNullable = { id: f0, params: f1, returns: f2, lifetime: f3, invoke: f4 }; + return o; +} + +function encT87(w: IrWriter, v: NonNullable): void { + w.keys(Object.keys(v), KEYS215); + w.str(v.release); + encT89(w, v.params); + w.str(v.returns); +} +function decT87(r: IrReader): NonNullable { + const f0 = r.str(); + const f1 = decT89(r); + const f2 = (r.str() as NonNullable["returns"]>); + const o: NonNullable = { release: f0, params: f1, returns: f2 }; + return o; +} + +function encT88(w: IrWriter, v: NonNullable[number]>["values"]>[number]>): void { + if (typeof v === "string") { + w.length(0); + w.str(v); + return; + } + if (typeof v === "number") { + w.length(1); + w.num(v); + return; + } + if (typeof v === "boolean") { + w.length(2); + w.bool(v); + return; + } + throw new Error("IR codec: no arm of T88 matches"); +} +function decT88(r: IrReader): NonNullable[number]>["values"]>[number]> { + switch (r.length()) { + case 0: + return r.str() as NonNullable[number]>["values"]>[number]>; + case 1: + return r.num() as NonNullable[number]>["values"]>[number]>; + case 2: + return r.bool() as NonNullable[number]>["values"]>[number]>; + } + throw new Error("IR codec: invalid T88 arm"); +} + +function encT89(w: IrWriter, v: NonNullable["params"]>): void { + w.length(v.length); + for (const item of v) { + encT90(w, item); + } +} +function decT89(r: IrReader): NonNullable["params"]> { + const length = r.length(); + const out: NonNullable["params"]>[number][] = []; + for (let i = 0; i < length; i++) out.push(decT90(r)); + return out; +} + +function encT90(w: IrWriter, v: NonNullable["params"]>[number]>): void { + if (typeof v === "string") { + w.length(0); + w.str(v); + return; + } + if (true) { + w.length(1); + encIrFfiContextParam(w, v as Exclude["params"]>[number]>, string | number | boolean | readonly unknown[]>); + return; + } + throw new Error("IR codec: no arm of T90 matches"); +} +function decT90(r: IrReader): NonNullable["params"]>[number]> { + switch (r.length()) { + case 0: + return r.str() as NonNullable["params"]>[number]>; + case 1: + return decIrFfiContextParam(r) as NonNullable["params"]>[number]>; + } + throw new Error("IR codec: invalid T90 arm"); +} diff --git a/packages/compiler/src/ir/codec.test.ts b/packages/compiler/src/ir/codec.test.ts new file mode 100644 index 000000000..538bd85de --- /dev/null +++ b/packages/compiler/src/ir/codec.test.ts @@ -0,0 +1,235 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../index.js"; +import { + decodeFunctionBinary, + decodeModuleBinary, + encodeFunctionBinary, + encodeFunctionInOrder, + encodeFunctionStrings, + encodeModuleBinary, + functionDigest, +} from "./codec.js"; +import { IR_CODEC_LAYOUT } from "./codec.generated.js"; +import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js"; +import { deserializeModuleBytes } from "./serialize.js"; + +// Programs with many node kinds: classes, closures, async, generators, +// dynamic values, streams, records, regexes and lone surrogates. +const PROGRAMS = [ + "1003-json-parse-unions.ts", + "114-live-record-delete.ts", + "1200-regex-test-basics.ts", + "1631-inspect-arrays.ts", + "4900-console-instances.ts", + "4991-lone-surrogate-halves.ts", + "5612-readable-from-iterables.cjs", + "7110-als-completion-callbacks.ts", + "7321-speculative-loop-bail-sites.ts", +]; + +/** Whether `a` and `b` hold the same data: the same keys with defined + * values, in any order, and numbers equal by Object.is. */ +function sameData(a: unknown, b: unknown): boolean { + if (typeof a === "number" && typeof b === "number") return Object.is(a, b); + if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return a === b; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const record = (value: object) => value as Record; + const keys = (value: object) => + Object.keys(value) + .filter((key) => record(value)[key] !== undefined) + .sort(); + const ka = keys(a); + const kb = keys(b); + return ( + ka.length === kb.length && + ka.every((key, index) => key === kb[index] && sameData(record(a)[key], record(b)[key])) + ); +} + +test.each(PROGRAMS)("%s round-trips through the binary encoding", async (program) => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-codec-")); + try { + const outPath = join(dir, "main.ir.json"); + const entry = join(import.meta.dirname, "../../../../tests/corpus", program); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const bytes = encodeModuleBinary(mod); + const back = decodeModuleBinary(bytes); + expect(sameData(mod, back)).toBe(true); + expect(Buffer.compare(encodeModuleBinary(back), bytes)).toBe(0); + // The JSON reader takes the binary encoding too. + expect(sameData(deserializeModuleBytes(bytes), mod)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +const loc = { file: "/src/main.ts", start: 0, end: 1 }; +const fn = (body: IrFunction["body"]): IrFunction => ({ + name: "%main", + params: [], + locals: [], + body, + returnType: VOID, + loc, +}); + +test("numbers keep NaN, the infinities, -0 and integers outside int32", () => { + const values = [ + NaN, + Infinity, + -Infinity, + -0, + 0, + 1, + -1, + 2 ** 31, + -(2 ** 31) - 1, + 2 ** 53, + 0.1, + -1.5e300, + ]; + const source = fn( + values.map((value) => ({ + kind: "exprStmt", + expr: { kind: "numLit", value, type: F64, loc }, + loc, + })), + ); + const back = decodeFunctionBinary(encodeFunctionBinary(source)); + const read = back.body.map((s) => + s.kind === "exprStmt" && s.expr.kind === "numLit" ? s.expr.value : null, + ); + expect(read.length).toBe(values.length); + read.forEach((value, index) => expect(Object.is(value, values[index])).toBe(true)); +}); + +test("strings keep lone surrogates and repeat through the string table", () => { + const texts = ["plain", "\ud83d", "a\udc00b", "😀", "plain", "é"]; + const source = fn( + texts.map((value) => ({ + kind: "exprStmt", + expr: { kind: "strLit", value, type: STRING, loc }, + loc, + })), + ); + const back = decodeFunctionBinary(encodeFunctionBinary(source)); + expect( + back.body.map((s) => (s.kind === "exprStmt" && s.expr.kind === "strLit" ? s.expr.value : null)), + ).toEqual(texts); +}); + +test("absent optional fields stay absent", () => { + const source: IrModule = { + irVersion: 17, + sourceFile: "/src/main.ts", + functions: [fn([])], + entry: "%main", + }; + const back = decodeModuleBinary(encodeModuleBinary(source)); + expect(Object.keys(back).sort()).toEqual(["entry", "functions", "irVersion", "sourceFile"]); + expect(Object.keys(back.functions[0]!).sort()).toEqual(Object.keys(source.functions[0]!).sort()); +}); + +test("a function whose objects follow the declared field order reads back with its key order", () => { + // As lowering builds functions: the source name after the name, and the + // async flag set after the literal. + const source: IrFunction = { + name: "%f", + sourceName: "f", + ownsPrototype: true, + params: [], + returnType: VOID, + locals: [{ id: "x.0", name: "x", type: F64, mutable: true }], + body: [{ kind: "return", value: null, loc }], + loc, + }; + source.async = true; + const bytes = encodeFunctionInOrder(source); + expect(bytes).not.toBeNull(); + const back = decodeFunctionBinary(bytes!); + expect(JSON.stringify(back)).toBe(JSON.stringify(source)); + expect(Object.keys(back)).toEqual(Object.keys(source)); +}); + +test("a function with an object out of the declared field order has no in-order encoding", () => { + const source = fn([{ kind: "return", value: null, loc }]); + // `loc` first: spreading the rest keeps its place. + const { loc: first, ...rest } = source; + const shuffled = { loc: first, ...rest } as IrFunction; + expect(encodeFunctionInOrder(shuffled)).toBeNull(); + // An object with a key its type does not declare. + expect(encodeFunctionInOrder({ ...source, extra: 1 } as IrFunction)).toBeNull(); + // The helper above builds `locals` before `returnType`. + expect(encodeFunctionInOrder(source)).toBeNull(); + const ordered: IrFunction = { + name: source.name, + params: [], + returnType: VOID, + locals: [], + body: source.body, + loc, + }; + expect(encodeFunctionInOrder(ordered)).not.toBeNull(); +}); + +test("a node whose kind its union does not declare is refused", () => { + const source = fn([{ kind: "noSuchStatement", loc } as never]); + expect(encodeFunctionInOrder(source)).toBeNull(); + expect(() => encodeFunctionBinary(source)).toThrow(/cannot encode a IrStmt kind noSuchStatement/); +}); + +test("an encoding lists its strings and the files its fields named file hold", () => { + const other = { file: "/src/other.ts", start: 2, end: 3 }; + const source: IrFunction = { + name: "%f", + params: [], + returnType: VOID, + locals: [], + body: [ + { + kind: "exprStmt", + expr: { kind: "strLit", value: "%named", type: STRING, loc: other }, + loc, + }, + ], + loc, + }; + const encoded = encodeFunctionStrings(source)!; + expect(encoded.strings).toEqual(expect.arrayContaining(["%f", "%named", "/src/main.ts"])); + expect([...encoded.files].sort()).toEqual(["/src/main.ts", "/src/other.ts"]); + expect(Buffer.compare(encoded.bytes, encodeFunctionInOrder(source)!)).toBe(0); +}); + +test("a function's digest follows its content and not its key order", () => { + const source = fn([{ kind: "return", value: null, loc }]); + const digest = functionDigest(source); + expect(digest).toMatch(/^\d+:[0-9a-f]{16}$/); + expect(functionDigest(fn([{ kind: "return", value: null, loc }]))).toBe(digest); + // The same fields in another order. + const { loc: first, ...rest } = source; + expect(functionDigest({ loc: first, ...rest } as IrFunction)).toBe(digest); + // A change in place. + source.locals.push({ id: "x.0", name: "x", type: F64, mutable: true }); + expect(functionDigest(source)).not.toBe(digest); + // A node the encoder refuses is covered by the function's JSON text. + const refused = fn([{ kind: "noSuchStatement", loc } as never]); + const text = functionDigest(refused); + expect(text).toMatch(/^json:/); + (refused.body[0] as unknown as { extra: number }).extra = 1; + expect(functionDigest(refused)).not.toBe(text); +}); + +test("bytes another layout wrote are refused", () => { + const bytes = Buffer.from(encodeFunctionBinary(fn([]))); + // The layout digest follows the magic as the first new string. + const at = bytes.indexOf(Buffer.from(IR_CODEC_LAYOUT)); + bytes[at] = bytes[at] === 0x30 ? 0x31 : 0x30; + expect(() => decodeFunctionBinary(bytes)).toThrow(/layout/); + expect(() => decodeFunctionBinary(Buffer.from("{}"))).toThrow(/not an IR encoding/); +}); diff --git a/packages/compiler/src/ir/codec.ts b/packages/compiler/src/ir/codec.ts new file mode 100644 index 000000000..301533903 --- /dev/null +++ b/packages/compiler/src/ir/codec.ts @@ -0,0 +1,376 @@ +/* The IR's binary encoding: the fields of every node and record, by name in + * the order ir.ts declares them (codec.generated.ts), written without field + * names or JSON structure. + * + * - A length, a union's member or arm index and a string reference are + * unsigned varints (7 bits per byte, low bits first). + * - A number is one byte, 0 for an integer in the int32 range other than + * -0, followed by its zigzag varint, or 1 followed by its IEEE 754 bytes + * (little-endian), which keep NaN, the infinities and -0. + * - A string is a varint: 0 for a new string, which also gives it the next + * index in the string table, otherwise one more than the index of a + * string the encoding already has. A new string's bytes follow, after a + * varint of twice their count: plus one when they are UTF-16LE code units + * (a string with a lone surrogate, which UTF-8 cannot spell), else UTF-8. + * - A boolean is one byte. + * - A field that can be absent or null starts with a byte: 0 absent, then 1 + * for null and 2 for a value when both are possible, otherwise 1 for a + * value. + * + * An encoding starts with MAGIC and IR_CODEC_LAYOUT, a digest of the + * generated coders, so a reader refuses bytes that other coders wrote. Decoded objects carry their fields in + * declaration order, with absent optional fields left out; an object read + * back has the key order of the object written when IrWriter.inOrder held. */ +import type { IrFunction, IrModule } from "./ir.js"; +import { + IR_CODEC_LAYOUT, + decodeIrFunction, + decodeIrModule, + encodeIrFunction, + encodeIrModule, +} from "./codec.generated.js"; + +/** The first bytes of an encoding: "SCIR". */ +const MAGIC = [0x53, 0x43, 0x49, 0x52]; + +/** Whether `value` has a surrogate code unit without its pair. */ +function loneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index); + if (unit < 0xd800 || unit > 0xdfff) continue; + const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0; + if (unit > 0xdbff || next < 0xdc00 || next > 0xdfff) return true; + index++; + } + return false; +} + +export class IrWriter { + private bytes = Buffer.allocUnsafe(1 << 16); + private size = 0; + private readonly strings = new Map(); + /** The values of the string fields named `file` written so far. */ + readonly files = new Set(); + private readonly scratch = Buffer.allocUnsafe(8); + /** Whether every object written so far reads back with its own key order + * (`keys`). False once one held a key its type does not declare, or its + * keys in another order than the declaration's. */ + inOrder = true; + + /** Notes an object's own keys against its type's declared fields. The + * decoder builds an object with its fields in declaration order, so an + * object whose keys come in another order, or include one the type does + * not declare, does not read back as it was. */ + keys(own: readonly string[], declared: readonly string[]): void { + if (!this.inOrder) return; + let at = 0; + for (const key of own) { + while (at < declared.length && declared[at] !== key) at++; + if (at === declared.length) { + this.inOrder = false; + return; + } + at++; + } + } + + private reserve(count: number): void { + if (this.size + count <= this.bytes.length) return; + let capacity = this.bytes.length * 2; + while (capacity < this.size + count) capacity *= 2; + const grown = Buffer.allocUnsafe(capacity); + this.bytes.copy(grown, 0, 0, this.size); + this.bytes = grown; + } + + byte(value: number): void { + this.reserve(1); + this.bytes[this.size++] = value; + } + + /** A non-negative integer below 2^53. */ + length(value: number): void { + this.reserve(8); + while (value >= 0x80) { + this.bytes[this.size++] = (value % 0x80) + 0x80; + value = Math.floor(value / 0x80); + } + this.bytes[this.size++] = value; + } + + bool(value: boolean): void { + this.byte(value ? 1 : 0); + } + + num(value: number): void { + if ( + Number.isInteger(value) && + value >= -0x80000000 && + value <= 0x7fffffff && + !Object.is(value, -0) + ) { + this.byte(0); + this.length(value < 0 ? -value * 2 - 1 : value * 2); + return; + } + this.byte(1); + this.scratch.writeDoubleLE(value, 0); + this.reserve(8); + this.scratch.copy(this.bytes, this.size, 0, 8); + this.size += 8; + } + + str(value: string): void { + const known = this.strings.get(value); + if (known !== undefined) { + this.length(known + 1); + return; + } + this.strings.set(value, this.strings.size); + this.length(0); + const utf8 = Buffer.byteLength(value, "utf8"); + // UTF-8 cannot spell a lone surrogate, so a string with one is written + // as its UTF-16 code units. + const units = utf8 !== value.length && loneSurrogate(value); + const count = units ? value.length * 2 : utf8; + this.length(count * 2 + (units ? 1 : 0)); + this.reserve(count); + if (units) this.bytes.write(value, this.size, count, "utf16le"); + else this.bytes.write(value, this.size, count, "utf8"); + this.size += count; + } + + /** What the encoding could not write, such as a node whose kind its + * union does not declare, or null. Such an encoding does not decode. */ + refused: string | null = null; + + /** Notes that `what` could not be written (`refused`). */ + refuse(what: string): void { + this.inOrder = false; + this.refused ??= what; + } + + /** A string field named `file`: a source file the encoding names. */ + file(value: string): void { + this.files.add(value); + this.str(value); + } + + /** Raw bytes, after their count. */ + blob(bytes: Uint8Array): void { + this.length(bytes.length); + this.reserve(bytes.length); + Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).copy(this.bytes, this.size); + this.size += bytes.length; + } + + /** The strings written so far, each once, in the order first written. */ + written(): IterableIterator { + return this.strings.keys(); + } + + /** Starts a new encoding in the same buffer. */ + reset(): void { + this.size = 0; + this.strings.clear(); + this.files.clear(); + this.inOrder = true; + this.refused = null; + } + + /** The bytes written so far. Throws when the encoding refused a value. */ + finish(): Buffer { + if (this.refused !== null) throw new Error(`IR codec: cannot encode a ${this.refused}`); + return this.bytes.subarray(0, this.size); + } +} + +export class IrReader { + private offset = 0; + private readonly strings: string[] = []; + /** The last presence byte `tag` read. */ + lastTag = 0; + + constructor(private readonly bytes: Buffer) {} + + private fail(what: string): never { + throw new Error(`IR codec: ${what} at byte ${this.offset}`); + } + + byte(): number { + if (this.offset >= this.bytes.length) this.fail("unexpected end"); + return this.bytes[this.offset++]!; + } + + length(): number { + let value = 0; + let scale = 1; + for (;;) { + const byte = this.byte(); + value += (byte % 0x80) * scale; + if (byte < 0x80) return value; + scale *= 0x80; + if (scale > 2 ** 56) this.fail("varint too long"); + } + } + + bool(): boolean { + return this.byte() !== 0; + } + + num(): number { + const tag = this.byte(); + if (tag === 0) { + const zigzag = this.length(); + return zigzag % 2 === 0 ? zigzag / 2 : -(zigzag + 1) / 2; + } + if (tag !== 1) this.fail("invalid number tag"); + if (this.offset + 8 > this.bytes.length) this.fail("unexpected end"); + const value = this.bytes.readDoubleLE(this.offset); + this.offset += 8; + return value; + } + + str(): string { + const reference = this.length(); + if (reference > 0) { + const known = this.strings[reference - 1]; + if (known === undefined) this.fail("invalid string reference"); + return known; + } + const spelled = this.length(); + const count = Math.floor(spelled / 2); + if (this.offset + count > this.bytes.length) this.fail("unexpected end"); + const value = + spelled % 2 === 0 + ? this.bytes.toString("utf8", this.offset, this.offset + count) + : this.bytes.toString("utf16le", this.offset, this.offset + count); + this.offset += count; + this.strings.push(value); + return value; + } + + /** Raw bytes blob wrote, as a view of the bytes read. */ + blob(): Buffer { + const count = this.length(); + if (this.offset + count > this.bytes.length) this.fail("unexpected end"); + const out = this.bytes.subarray(this.offset, this.offset + count); + this.offset += count; + return out; + } + + /** A presence byte below `limit`, kept in lastTag. */ + tag(limit: number, where: string): number { + const tag = this.byte(); + if (tag >= limit) this.fail(`invalid presence byte for ${where}`); + this.lastTag = tag; + return tag; + } + + /** Whether every byte was read. */ + done(): boolean { + return this.offset === this.bytes.length; + } +} + +function header(w: IrWriter): void { + for (const byte of MAGIC) w.byte(byte); + w.str(IR_CODEC_LAYOUT); +} + +function readHeader(r: IrReader): void { + for (const byte of MAGIC) if (r.byte() !== byte) throw new Error("IR codec: not an IR encoding"); + const layout = r.str(); + if (layout !== IR_CODEC_LAYOUT) + throw new Error( + `IR codec: the encoding has layout ${layout}, this compiler reads ${IR_CODEC_LAYOUT}`, + ); +} + +/** Whether `bytes` start like an encoding this module writes. */ +export function isIrEncoding(bytes: Uint8Array): boolean { + return MAGIC.every((byte, index) => bytes[index] === byte); +} + +export function encodeModuleBinary(mod: IrModule): Buffer { + const w = new IrWriter(); + header(w); + encodeIrModule(w, mod); + return w.finish(); +} + +export function decodeModuleBinary(bytes: Buffer): IrModule { + const r = new IrReader(bytes); + readHeader(r); + const mod = decodeIrModule(r); + if (!r.done()) throw new Error("IR codec: bytes after the module"); + return mod; +} + +export function encodeFunctionBinary(fn: IrFunction): Buffer { + const w = new IrWriter(); + header(w); + encodeIrFunction(w, fn); + return w.finish(); +} + +/** The writer encodeFunctionInOrder reuses: its result is a copy. */ +const inOrderWriter = new IrWriter(); + +/** `fn`'s encoding when it reads back as `fn` is, every object with its + * own key order (IrWriter.inOrder), or null. */ +export function encodeFunctionInOrder(fn: IrFunction): Buffer | null { + return encodeFunctionStrings(fn)?.bytes ?? null; +} + +/** encodeFunctionInOrder's encoding of `fn`, with the strings it holds, + * each once, and the files its fields named `file` hold (IrWriter.file), or + * null. */ +export function encodeFunctionStrings(fn: IrFunction): { + readonly bytes: Buffer; + readonly strings: readonly string[]; + readonly files: readonly string[]; +} | null { + const w = inOrderWriter; + w.reset(); + header(w); + encodeIrFunction(w, fn); + // An encoding that refused a value is not in order either. + return w.inOrder + ? { bytes: Buffer.from(w.finish()), strings: [...w.written()], files: [...w.files] } + : null; +} + +/** A 64-bit digest of `fn`'s content, the same for functions whose fields + * hold the same values in any key order, with the byte count of the + * content it covers. It covers the binary encoding, or the JSON text when + * the encoder refuses a value (IrWriter.refuse). */ +export function functionDigest(fn: IrFunction): string { + const w = inOrderWriter; + w.reset(); + encodeIrFunction(w, fn); + if (w.refused === null) return bytesDigest(w.finish()); + return `json:${bytesDigest(Buffer.from(JSON.stringify(fn), "utf8"))}`; +} + +/** Two 32-bit multiply-xor lanes over `bytes`, FNV-1a's and one with + * another offset and MurmurHash2's multiplier, and the byte count. */ +function bytesDigest(bytes: Uint8Array): string { + let a = 0x811c9dc5; + let b = 0x050c5d1f; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]!; + a = Math.imul(a ^ byte, 0x01000193); + b = Math.imul(b ^ byte, 0x5bd1e995); + } + const hex = (lane: number): string => (lane >>> 0).toString(16).padStart(8, "0"); + return `${bytes.length}:${hex(a)}${hex(b)}`; +} + +export function decodeFunctionBinary(bytes: Buffer): IrFunction { + const r = new IrReader(bytes); + readHeader(r); + const fn = decodeIrFunction(r); + if (!r.done()) throw new Error("IR codec: bytes after the function"); + return fn; +} diff --git a/packages/compiler/src/ir/fields.generated.ts b/packages/compiler/src/ir/fields.generated.ts index ae45b987b..124d4a483 100644 --- a/packages/compiler/src/ir/fields.generated.ts +++ b/packages/compiler/src/ir/fields.generated.ts @@ -802,14 +802,14 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo case "classRef": { walker.string(node.kind); walker.string(node.className); + if (!skipType) { + walkTypeFields(node.type, walker, skipType); + } if (node.captures !== undefined && node.captures !== null) { for (const item6 of node.captures) { walker.string(item6); } } - if (!skipType) { - walkTypeFields(node.type, walker, skipType); - } return; } case "closure": { @@ -890,12 +890,12 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo case "dynFrom": { walker.string(node.kind); walkExprFields(node.value, walker, skipType); - if (node.fnName !== undefined && node.fnName !== null) { - walker.string(node.fnName); - } if (!skipType) { walkTypeFields(node.type, walker, skipType); } + if (node.fnName !== undefined && node.fnName !== null) { + walker.string(node.fnName); + } return; } case "dynHasKey": { @@ -1635,14 +1635,14 @@ export function walkStmtFields(node: IrStmt, walker: IrFieldWalker, skipType: bo } case "block": { walker.string(node.kind); - for (const item30 of node.body) { - walkStmtFields(item30, walker, skipType); - } if (node.labels !== undefined && node.labels !== null) { - for (const item31 of node.labels) { - walker.string(item31); + for (const item30 of node.labels) { + walker.string(item30); } } + for (const item31 of node.body) { + walkStmtFields(item31, walker, skipType); + } return; } case "break": { @@ -2111,7 +2111,7 @@ export type IrFieldsCurrent = [ Unread["negated"]>>, Unread, "kind" | "value" | "type" | "loc">>, Unread, "kind" | "id" | "type" | "loc">>, - Unread, "kind" | "className" | "captures" | "type" | "loc">>, + Unread, "kind" | "className" | "type" | "loc" | "captures">>, Unread, "kind" | "fnName" | "captures" | "adapts" | "type" | "loc">>, Unread["adapts"]>>, Unread, "kind" | "value" | "type" | "loc">>, @@ -2125,9 +2125,9 @@ export type IrFieldsCurrent = [ Unread["exact"]>>, Unread["origin"]>>, Unread, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>, - Unread, "kind" | "value" | "fnName" | "fnLength" | "liveRef" | "type" | "loc">>, - Unread["fnLength"]>>, + Unread, "kind" | "value" | "type" | "loc" | "liveRef" | "fnName" | "fnLength">>, Unread["liveRef"]>>, + Unread["fnLength"]>>, Unread, "kind" | "key" | "negated" | "value" | "type" | "loc">>, Unread["negated"]>>, Unread, "kind" | "recv" | "method" | "calleeName" | "calleeNameValue" | "args" | "type" | "loc">>, @@ -2240,7 +2240,7 @@ export type IrFieldsCurrent = [ Unread, "kind" | "arr" | "index" | "loc">>, Unread, "kind" | "localId" | "value" | "initializes" | "loc">>, Unread["initializes"]>>, - Unread, "kind" | "body" | "labels" | "loc">>, + Unread, "kind" | "labels" | "body" | "loc">>, Unread, "kind" | "label" | "loc">>, Unread, "kind" | "arr" | "index" | "value" | "loc">>, Unread, "kind" | "label" | "loc">>, @@ -2264,9 +2264,9 @@ export type IrFieldsCurrent = [ Unread["cases"]>, readonly unknown[]>[number]>, object>, "test" | "body">>, Unread, "kind" | "disc" | "cases" | "labels" | "loc">>, Unread, "kind" | "value" | "loc">>, - Unread, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "finallyBody" | "suppressFinallyErrors" | "dropFinallyErrors" | "loc">>, - Unread["suppressFinallyErrors"]>>, + Unread, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "dropFinallyErrors" | "finallyBody" | "suppressFinallyErrors" | "loc">>, Unread["dropFinallyErrors"]>>, + Unread["suppressFinallyErrors"]>>, Unread, "kind" | "localId" | "init" | "loc">>, Unread, "kind" | "cond" | "body" | "labels" | "loc">>, Unread, "kind" | "elem" | "optionalElements">>, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 63814a60c..c58d287fa 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1964,14 +1964,10 @@ export interface IrFunction { /** Original TS name (mangling is a backend concern). Lifted lambdas get * synthetic '%'-prefixed names ('%' can't appear in a TS identifier). */ name: string; - /** Generated reflection dispatch contains speculative source-member edges. - * Target-only host operations behind those edges retain runtime refusals. */ - speculativeDispatch?: boolean; - /** A read helper the backend can answer directly for dyn arrays and live - * array capsules, calling the helper only when that read has no answer. - * `index`: a computed read `(value, key)` whose callers pass a number - * converted to a string as the key. */ - fastRead?: "index"; + /** Source function name for native error stack frames. */ + sourceName?: string; + /** Ordinary authored function declarations/expressions own a prototype. */ + ownsPrototype?: true; params: IrParam[]; returnType: IrType; /** All locals including params, pre-collected and scope-flat: ids are @@ -1986,22 +1982,29 @@ export interface IrFunction { * their first (`this`) parameter. The slot indexes the class object's * capture array; no closure parameter is added to the method ABI. */ classCaptures?: (IrParam & { slot: number })[]; + body: IrStmt[]; + loc: SrcLoc; + /** The function's execution tier (ir/tiers.ts). Absent means static: the + * function may use dynamic values only through boundary operations + * (dynFrom, dynCheck, kind tests) and moves, which the validator enforces + * (invariant I1). "dynamic" admits the dynamic operation vocabulary + * (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field + * from the body (assignLegacyTiers); stage S2 of the two-tier design + * keeps it out of serialized IR, so loaders derive it again. */ + tier?: "dynamic"; + /** Generated reflection dispatch contains speculative source-member edges. + * Target-only host operations behind those edges retain runtime refusals. */ + speculativeDispatch?: boolean; + /** A read helper the backend can answer directly for dyn arrays and live + * array capsules, calling the helper only when that read has no answer. + * `index`: a computed read `(value, key)` whose callers pass a number + * converted to a string as the key. */ + fastRead?: "index"; /** Async: the body runs on a fiber; `returnType` is the INNER type T (a * `return v` fulfills with v) while ordinary call sites receive * Promise. With `generator` present, call sites receive the lazy * async-generator object instead. */ async?: true; - /** Async module initializers only: a module-global Promise slot where - * the spawn wrapper caches its first evaluation promise. Every later - * static/dynamic import receives that same promise, including while the - * first evaluation is suspended. */ - asyncCacheGlobal?: string; - /** Async cyclic module initializers only: the SCC's shared Promise - * slot. Eager recursive spawning writes member promises from the inside - * out, so the member that actually initiated evaluation writes last and - * becomes the runtime cycle root. Dynamic imports wait on this shared - * completion verdict instead of a build-time-selected member. */ - asyncCycleCacheGlobal?: string; /** Generator (`function*` / `async function*`): the body runs on a fiber created SUSPENDED * (nothing runs until the first `.next()`; backends may instead compile a * synchronous body as a stackless coroutine with the same protocol); `returnType` is the @@ -2030,20 +2033,17 @@ export interface IrFunction { * that generator's tag. */ inheritsTag?: true; }; - /** Source function name for native error stack frames. */ - sourceName?: string; - /** Ordinary authored function declarations/expressions own a prototype. */ - ownsPrototype?: true; - /** The function's execution tier (ir/tiers.ts). Absent means static: the - * function may use dynamic values only through boundary operations - * (dynFrom, dynCheck, kind tests) and moves, which the validator enforces - * (invariant I1). "dynamic" admits the dynamic operation vocabulary - * (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field - * from the body (assignLegacyTiers); stage S2 of the two-tier design - * keeps it out of serialized IR, so loaders derive it again. */ - tier?: "dynamic"; - body: IrStmt[]; - loc: SrcLoc; + /** Async module initializers only: a module-global Promise slot where + * the spawn wrapper caches its first evaluation promise. Every later + * static/dynamic import receives that same promise, including while the + * first evaluation is suspended. */ + asyncCacheGlobal?: string; + /** Async cyclic module initializers only: the SCC's shared Promise + * slot. Eager recursive spawning writes member promises from the inside + * out, so the member that actually initiated evaluation writes last and + * becomes the runtime cycle root. Dynamic imports wait on this shared + * completion verdict instead of a build-time-selected member. */ + asyncCycleCacheGlobal?: string; } export interface IrParam { @@ -2280,7 +2280,7 @@ export type IrStmt = /** A bare lexical block `{ ... }` — its own scope. `labels` makes it a * labeled-break target (`lbl: { ... break lbl; ... }` and the wrapper * the frontend puts around labeled non-loop statements). */ - | { kind: "block"; body: IrStmt[]; labels?: string[]; loc: SrcLoc } + | { kind: "block"; labels?: string[]; body: IrStmt[]; loc: SrcLoc } /** `throw v`. Any non-void value type can be thrown; ownership of a * refcounted value MOVES into the runtime's exception cell. Terminates * the path like `return`: control unwinds to the innermost enclosing @@ -2324,15 +2324,15 @@ export type IrStmt = tryBody: IrStmt[]; catchBody: IrStmt[] | null; catchLocalId: string | null; - finallyBody: IrStmt[] | null; - /** Resource-management cleanup: if the guarded body and cleanup both - * throw, raise a SuppressedError instead of replacing the first error. */ - suppressFinallyErrors?: true; /** Iterator closing: if the guarded body threw and the cleanup throws * too, the cleanup's error is dropped and the first one propagates. * After a generator .return() the cleanup's error replaces it, and on * normal exits it propagates. */ dropFinallyErrors?: true; + finallyBody: IrStmt[] | null; + /** Resource-management cleanup: if the guarded body and cleanup both + * throw, raise a SuppressedError instead of replacing the first error. */ + suppressFinallyErrors?: true; loc: SrcLoc; }; @@ -3341,7 +3341,7 @@ export type IrExpr = * every classRef, so a value's construct thunk always has a constructor * to call; backends emit class objects (and thunks) for exactly the * classes some classRef in the module names. */ - | { kind: "classRef"; className: string; captures?: string[]; type: IrType; loc: SrcLoc } + | { kind: "classRef"; className: string; type: IrType; loc: SrcLoc; captures?: string[] } /** `new X(args)` through a class VALUE: call the class object's * construct thunk. `callee` is classval-typed; args are completed * against `%.constructor`'s ABI — sound because every @@ -3513,13 +3513,13 @@ export type IrExpr = | { kind: "dynFrom"; value: IrExpr; + type: IrType; + loc: SrcLoc; + liveRef?: true; fnName?: string; /** A boxed function's `length` when it differs from its parameter * count (builtin function values). */ fnLength?: number; - liveRef?: true; - type: IrType; - loc: SrcLoc; } /** CALLING a dyn value — `fn(a, b)` where fn is checked-dynamic (an * implicit-any JS binding, a dyn record member, a dynKeyGet result). diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index 185472b89..d8ff2e273 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -7,6 +7,7 @@ * text parts and decoded from bytes one function at a time; neither direction * materializes the complete document as a single string. */ import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { decodeModuleBinary, isIrEncoding } from "./codec.js"; // Version 17 adds the dynamic tier's jsval type and js.* builtins (two-tier // design stage S3) and serializes each function's execution tier. @@ -268,10 +269,12 @@ function functionSpans(bytes: Buffer): FunctionSpans | null { return null; } -/** Decode a UTF-8 IR artifact (compact or readable) without materializing - * it as one string: the header and each function are parsed separately. +/** Decode an IR artifact: the binary encoding (codec.ts), or UTF-8 JSON + * (compact or readable) without materializing it as one string: the header + * and each function are parsed separately. * Split points are ASCII structural bytes, so every range is valid UTF-8. */ export function deserializeModuleBytes(bytes: Buffer): IrModule { + if (isIrEncoding(bytes)) return decodeModuleBinary(bytes); const spans = functionSpans(bytes); if (spans === null) return deserializeModule(bytes.toString("utf8")); const mod = checkedVersion( diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 551a151a1..d18108afb 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -174,6 +174,8 @@ export class NativeCache { "binary", "dsym", "coverage", + "units", + "unit-files", ]) { const directory = join(this.root, family); let names: string[]; diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index bf496aa99..753ad1281 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -32,6 +32,7 @@ import { import { compilerOwnedFileRoots } from "../frontend/dts-paths.js"; import { provenanceSources } from "../frontend/provenance-registry.js"; import { processFork } from "../frontend/process-fork.js"; +import { unitReuseEnabled, type UnitStore } from "../frontend/lowering/unit-reuse.js"; import { tokenEquivalentSource, tokenPositionEquivalentSource, @@ -196,6 +197,21 @@ function equivalentFrontendInput( }; } +/** The unit store of a build whose unit key is `key`: its program entry + * under that key, and file entries by content digest. */ +function nativeUnitStore(cache: NativeCache, key: string): UnitStore { + return { + readProgram: () => cache.read("units", key), + writeProgram: (bytes) => cache.write("units", key, bytes), + readFile: (name) => (/^[0-9a-f]{64}$/.test(name) ? cache.read("unit-files", name) : null), + writeFile: (bytes) => { + const digest = contentDigest(bytes); + if (!cache.has("unit-files", digest)) cache.write("unit-files", digest, bytes, digest); + return digest; + }, + }; +} + function implementationIdentity(toolchain: NativeToolchain): string { // Release versions alone cannot distinguish two development builds. return contentDigest( @@ -221,6 +237,9 @@ const FRONTEND_INDEPENDENT_VARIABLES = new Set([ "SCRIPTC_CHECKER_SERVER", "SCRIPTC_EMIT_WORKERS", "SCRIPTC_EMIT_WORKER_FUNCTIONS", + // Reusing lowered units leaves the lowered module as it is. + "SCRIPTC_INCR_LOWERING", + "SCRIPTC_INCR_VERIFY", "SCRIPTC_TIMING", ]); @@ -372,22 +391,28 @@ export function prepareNativeExecutable( const exclusions = { outputPaths, outputDirectories: [...directories] }; const irInput = process.env[BOOTSTRAP_IR_INPUT] ?? ""; let key: string | null = null; + // The unit store's key (unit-reuse.ts): the frontend's inputs without the + // output paths, which lowering does not read. + let unitKey: string | null = null; // The entry this build replaces: its probes give the digests of unchanged files. let previous: FrontendInputSnapshot | undefined; if (cache !== null && irInput === "" && provenanceSources() === null) { try { - key = contentDigest( - JSON.stringify({ - schema: 1, - entry, - options, - ffi, - implementation: implementationIdentity(toolchain), - target: toolchain.target.name, - declarations: toolchain.declarationsRoot ?? null, - environment: frontendEnvironment(), - }), - ); + const inputs = { + schema: 1, + entry, + options, + ffi, + implementation: implementationIdentity(toolchain), + target: toolchain.target.name, + declarations: toolchain.declarationsRoot ?? null, + environment: frontendEnvironment(), + }; + key = contentDigest(JSON.stringify(inputs)); + if (unitReuseEnabled()) { + const { outPath: _outPath, outDir: _outDir, ...lowering } = options; + unitKey = contentDigest(JSON.stringify({ ...inputs, options: lowering })); + } const cached = readFrontendCache(cache, key, exclusions); if (cached !== null && "input" in cached) { timing("frontend-cache-hit"); @@ -413,7 +438,17 @@ export function prepareNativeExecutable( irInput !== "" ? preparedFromIr(irInput, entry, options) : tracker.runSynchronous(() => - prepareExecutableModule(entry, options, ffi, toolchain.target.platform, frontend, timing), + prepareExecutableModule( + entry, + options, + ffi, + toolchain.target.platform, + frontend, + timing, + new Map(), + true, + cache !== null && unitKey !== null ? nativeUnitStore(cache, unitKey) : null, + ), ); if (!prepared.ok) return prepared; if (irInput !== "") timing("ir-input"); diff --git a/packages/compiler/test/unit-reuse.test.ts b/packages/compiler/test/unit-reuse.test.ts new file mode 100644 index 000000000..80393cf0f --- /dev/null +++ b/packages/compiler/test/unit-reuse.test.ts @@ -0,0 +1,315 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +const VARIABLES = [ + "SCRIPTC_CACHE_DIR", + "SCRIPTC_INCR_LOWERING", + "SCRIPTC_INCR_VERIFY", + "SCRIPTC_LOWER_JOURNAL_DUMP", +]; + +afterEach(() => { + for (const name of VARIABLES) delete process.env[name]; +}); + +/** A program in a scratch directory, built with unit reuse into its own + * cache and without it. */ +class Workspace { + private builds = 0; + constructor(readonly dir: string) {} + + static async create(files: Record): Promise { + const workspace = new Workspace(await mkdtemp(join(tmpdir(), "scriptc-unit-reuse-"))); + for (const [name, text] of Object.entries(files)) await workspace.write(name, text); + return workspace; + } + + write(name: string, text: string): Promise { + return writeFile(join(this.dir, name), text); + } + + async edit(name: string, from: string, to: string): Promise { + const text = await readFile(join(this.dir, name), "utf8"); + expect(text, `${name} spells ${from}`).toContain(from); + await this.write(name, text.replace(from, to)); + } + + private async lower(label: string, reuse: boolean, verify = false): Promise { + for (const name of VARIABLES) delete process.env[name]; + if (reuse) { + process.env["SCRIPTC_CACHE_DIR"] = join(this.dir, "cache"); + process.env["SCRIPTC_INCR_LOWERING"] = "1"; + process.env["SCRIPTC_LOWER_JOURNAL_DUMP"] = join(this.dir, label); + if (verify) process.env["SCRIPTC_INCR_VERIFY"] = "1"; + } + const outPath = join(this.dir, `${label}.ir.json`); + const result = await compile(join(this.dir, "main.ts"), { + outDir: join(this.dir, "out"), + outPath, + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return readFile(outPath, "utf8"); + } + + /** The units with a record in the last build, recorded or applied. */ + units: string[] = []; + + /** Builds with reuse, checks the IR against a build without it, and + * returns the units whose stored records applied. A build in verify mode + * follows and must give the same IR. */ + async build(): Promise { + const label = `build-${this.builds++}`; + const reused = await this.lower(label, true); + expect(reused).toBe(await this.lower(`${label}-plain`, false)); + const applied: string[] = []; + this.units = []; + for (const line of (await readFile(join(this.dir, label, "records.jsonl"), "utf8")) + .trimEnd() + .split("\n")) { + // A unit without a record has a line with its reasons. + const record = JSON.parse(line) as { name: string; applied?: boolean; reasons?: string[] }; + if (record.reasons !== undefined) continue; + this.units.push(record.name); + if (record.applied === true) applied.push(record.name); + } + this.units.sort(); + expect(await this.lower(`${label}-verify`, true, true)).toBe(reused); + return applied.sort(); + } + + dispose(): Promise { + return rm(this.dir, { recursive: true, force: true }); + } +} + +const MODULES = { + "shapes.ts": [ + "export function area(width: number, height: number): number {", + " return width * height;", + "}", + "export function label(name: string): string {", + " return `shape ${name}`;", + "}", + "export class Box {", + " constructor(readonly width: number) {}", + " scaled(by: number): number {", + " return this.width * by;", + " }", + " named(): string {", + " return label(`box ${this.width}`);", + " }", + "}", + "", + ].join("\n"), + "report.ts": [ + 'import { Box, area } from "./shapes.js";', + "export function report(sizes: number[]): number {", + " let total = 0;", + " for (const size of sizes) total += area(size, size);", + " return total;", + "}", + "export function describe(box: Box): string {", + " return `${box.named()} ${box.scaled(2)}`;", + "}", + "export function twice(value: number): number {", + " return value * 2;", + "}", + "", + ].join("\n"), + "format.ts": [ + "export function pad(text: string, width: number): string {", + " return text.padStart(width);", + "}", + "", + ].join("\n"), + "main.ts": [ + 'import { Box, label } from "./shapes.js";', + 'import { describe, report, twice } from "./report.js";', + 'import { pad } from "./format.js";', + 'console.log(report([1, 2, 3]), describe(new Box(4)), label("main"), twice(3), pad("x", 4));', + "", + ].join("\n"), +}; + +/** Whether `applied` holds a unit named `name`. */ +const has = (applied: readonly string[], name: string): boolean => + applied.some((unit) => unit.endsWith(`.${name}`) || unit === name); + +test("an unchanged rebuild applies every unit's stored record", async () => { + const workspace = await Workspace.create(MODULES); + try { + // The first build into the cache records nothing; build()'s verify build + // after it finds the tables and records. + expect(await workspace.build()).toEqual([]); + const units = workspace.units; + expect(units.length).toBeGreaterThan(4); + expect(await workspace.build()).toEqual(units); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("a body edit lowers the units of its file and the units that spell its name", async () => { + const workspace = await Workspace.create(MODULES); + try { + await workspace.build(); + await workspace.edit("shapes.ts", "return width * height;", "return width * height + 1;"); + const applied = await workspace.build(); + // report() spells area(); twice() and pad() spell nothing whose + // declaration changed. The units of shapes.ts lower again. + expect(has(applied, "report")).toBe(false); + expect(has(applied, "twice")).toBe(true); + expect(has(applied, "pad")).toBe(true); + expect(has(applied, "scaled")).toBe(false); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("a signature edit lowers the units whose text spells a name it reaches", async () => { + const workspace = await Workspace.create(MODULES); + try { + await workspace.build(); + await workspace.edit( + "shapes.ts", + " named(): string {", + ' named(prefix = ""): string {\n if (prefix !== "") return prefix;', + ); + const applied = await workspace.build(); + // The member's surface changed, and with it the type of Box: the units + // that spell named or Box lower again, the others apply. + expect(has(applied, "named")).toBe(false); + expect(has(applied, "report")).toBe(true); + expect(has(applied, "twice")).toBe(true); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("an edit above every declaration of a file keeps the other files' units", async () => { + const workspace = await Workspace.create(MODULES); + try { + await workspace.build(); + await workspace.edit( + "shapes.ts", + "export function area", + "// A comment.\nexport function area", + ); + const applied = await workspace.build(); + expect(has(applied, "report")).toBe(true); + expect(has(applied, "twice")).toBe(true); + expect(has(applied, "scaled")).toBe(false); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("an added declaration keeps the units that do not spell its name", async () => { + const workspace = await Workspace.create(MODULES); + try { + await workspace.build(); + await workspace.edit( + "report.ts", + "export function describe", + "export function unused(): number {\n return 1;\n}\nexport function describe", + ); + const applied = await workspace.build(); + expect(has(applied, "scaled")).toBe(true); + expect(has(applied, "label")).toBe(true); + expect(has(applied, "pad")).toBe(true); + expect(has(applied, "twice")).toBe(false); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("units that use a module's namespace object lower again when its exports change", async () => { + const workspace = await Workspace.create({ + "values.ts": "export let count = 1;\nexport function bump(): void {\n count += 1;\n}\n", + "main.ts": [ + 'import * as values from "./values.js";', + "function keys(): string {", + ' return Object.keys(values).join(",");', + "}", + "function current(): number {", + " return values.count;", + "}", + "values.bump();", + "console.log(keys(), current());", + "", + ].join("\n"), + }); + try { + await workspace.build(); + expect(await workspace.build()).toEqual(workspace.units); + await workspace.write( + "values.ts", + "export let count = 1;\nexport function bump(): void {\n count += 1;\n}\nexport const added = 2;\n", + ); + // Both units spell the namespace binding, whose object gained a key. + const applied = await workspace.build(); + expect(has(applied, "keys")).toBe(false); + expect(has(applied, "current")).toBe(false); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("a unit whose record holds another file's positions lowers again when that file changes", async () => { + const workspace = await Workspace.create({ + "lib.ts": + 'export function add(a: number, b: number): number {\n return a + b;\n}\nexport const label = "lib";\n', + "main.ts": [ + 'import * as lib from "./lib.js";', + "function show(): string {", + " const { add: plus, label: name } = lib;", + " return `${name} ${plus(1, 2)}`;", + "}", + "function other(): number {", + " return 42;", + "}", + "console.log(show(), other());", + "", + ].join("\n"), + }); + try { + await workspace.build(); + await workspace.edit("lib.ts", "export function add", "// A comment.\nexport function add"); + // The build compares its IR, positions included, with a clean build's. + const applied = await workspace.build(); + expect(has(applied, "other")).toBe(true); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("the first build into a cache writes the declaration tables and no unit records", async () => { + const workspace = await Workspace.create(MODULES); + try { + const cache = join(workspace.dir, "cache"); + process.env["SCRIPTC_CACHE_DIR"] = cache; + process.env["SCRIPTC_INCR_LOWERING"] = "1"; + const build = async (): Promise => { + const result = await compile(join(workspace.dir, "main.ts"), { + outDir: join(workspace.dir, "out"), + outPath: join(workspace.dir, "main.ir.json"), + outputKind: "ir", + }); + expect(result.ok).toBe(true); + }; + const entries = async (family: string): Promise => + readdir(join(cache, "units-v1", family)).catch(() => []); + await build(); + expect(await entries("program")).toHaveLength(1); + expect(await entries("files")).toEqual([]); + await build(); + expect((await entries("files")).length).toBeGreaterThan(0); + } finally { + await workspace.dispose(); + } +}, 300_000); diff --git a/scripts/generate-ir-codec.mjs b/scripts/generate-ir-codec.mjs new file mode 100644 index 000000000..4cd04f6be --- /dev/null +++ b/scripts/generate-ir-codec.mjs @@ -0,0 +1,440 @@ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Generates packages/compiler/src/ir/codec.generated.ts from the types in +// ir.ts: for every object type an IrModule can reach, an encoder that +// writes its fields by name in declaration order and a decoder that reads +// them back into an object literal of that type. Unions that `kind` +// discriminates are written as the member's index; other unions as the +// index of the arm a runtime test picks. The byte format is in codec.ts. +// `--check` fails when the file is stale. +const root = fileURLToPath(new URL("..", import.meta.url)); +const require = createRequire(join(root, "package.json")); +const ts = require("typescript"); +const irPath = join(root, "packages/compiler/src/ir/ir.ts"); +const target = join(root, "packages/compiler/src/ir/codec.generated.ts"); + +const program = ts.createProgram([irPath], { strict: true, target: ts.ScriptTarget.ES2022 }); +const checker = program.getTypeChecker(); +const source = program.getSourceFile(irPath); + +/** Exported type names of ir.ts, by the type they declare. */ +const exported = new Map(); +ts.forEachChild(source, (node) => { + if ( + (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && + node.typeParameters === undefined + ) { + const type = checker.getTypeAtLocation(node.name); + if (!exported.has(type)) exported.set(type, node.name.text); + } +}); +/** Exported names by the member set of the union or the single type they + * declare: a field of type `IrExpr | undefined` codes its IrExpr part with + * IrExpr's coder. */ +const exportedBy = new Map(); +for (const [type, name] of exported) { + const parts = type.isUnion() ? type.types : [type]; + const key = parts + .map((p) => p.id) + .sort((a, b) => a - b) + .join("|"); + if (!exportedBy.has(key)) exportedBy.set(key, name); +} +const partsKey = (parts) => + parts + .map((p) => p.id) + .sort((a, b) => a - b) + .join("|"); +const used = new Set(["IrModule", "IrFunction"]); +const moduleType = [...exported].find(([, name]) => name === "IrModule")[0]; +const functionType = [...exported].find(([, name]) => name === "IrFunction")[0]; + +const F = ts.TypeFlags; +const NULLISH = F.Null | F.Undefined | F.Void; +const STRING = F.StringLike; +const NUMBER = F.NumberLike; +const BOOLEAN = F.BooleanLike; + +/** An intersection no value has: `IrType & { kind: "record" }` distributes + * over IrType's members, and all but one give `kind` two literal types. */ +function impossible(type) { + return type.isIntersection() && checker.typeToString(type) === "never"; +} + +/** The parts of `type` that are not null or undefined, and which of the two + * it allows. */ +function split(type) { + const parts = type.isUnion() ? type.types : [type]; + return { + present: parts.filter((part) => (part.flags & (NULLISH | F.Never)) === 0 && !impossible(part)), + nullable: parts.some((part) => (part.flags & F.Null) !== 0), + undefinable: parts.some((part) => (part.flags & (F.Undefined | F.Void)) !== 0), + }; +} + +/** The literal value of `kind` on an object type, or null. */ +function kindOf(type) { + const kind = checker.getPropertyOfType(type, "kind"); + if (kind === undefined) return null; + const kindType = checker.getTypeOfSymbol(kind); + return kindType.isStringLiteral() ? kindType.value : null; +} + +/** How a value of the non-nullish `parts` is coded. */ +function shapeOf(parts, where) { + for (const part of parts) + if ((part.flags & (F.Any | F.Unknown)) !== 0) throw new Error(`untyped value at ${where}`); + const objects = parts.filter((part) => (part.flags & (STRING | NUMBER | BOOLEAN)) === 0); + const strings = parts.filter((part) => (part.flags & STRING) !== 0); + const numbers = parts.filter((part) => (part.flags & NUMBER) !== 0); + const booleans = parts.filter((part) => (part.flags & BOOLEAN) !== 0); + if (objects.length === 0) { + const scalarKinds = [strings, numbers, booleans].filter((list) => list.length > 0).length; + if (scalarKinds === 1) + return strings.length > 0 + ? { code: "string" } + : numbers.length > 0 + ? { code: "number" } + : { code: "bool" }; + } + if (objects.length === 1 && strings.length + numbers.length + booleans.length === 0) + return checker.isArrayType(objects[0]) + ? { code: "array", item: objects[0] } + : { code: "object", type: objects[0] }; + if ( + objects.length > 1 && + strings.length + numbers.length + booleans.length === 0 && + objects.every((part) => kindOf(part) !== null && !checker.isArrayType(part)) + ) + return { code: "kinds", members: objects }; + // Arms told apart at run time: typeof for scalars, Array.isArray, a + // `kind` literal, or a property only one object arm declares. + const arms = []; + if (strings.length > 0) arms.push({ test: "string", parts: strings }); + if (numbers.length > 0) arms.push({ test: "number", parts: numbers }); + if (booleans.length > 0) arms.push({ test: "boolean", parts: booleans }); + const arrays = objects.filter((part) => checker.isArrayType(part)); + if (arrays.length > 1) throw new Error(`several array types at ${where}`); + if (arrays.length === 1) arms.push({ test: "array", parts: arrays }); + const records = objects.filter((part) => !checker.isArrayType(part)); + for (const record of records) { + const own = checker.getPropertiesOfType(record).map((p) => p.name); + const others = records.filter((other) => other !== record); + const required = checker + .getPropertiesOfType(record) + .filter((p) => (p.flags & ts.SymbolFlags.Optional) === 0) + .map((p) => p.name); + let key = required.find((name) => + others.every((other) => checker.getPropertyOfType(other, name) === undefined), + ); + // A property every arm has, whose object value has a key only this + // arm's does (IrFfiParam's callback.id and callback.release). + if (key === undefined) + for (const outer of required) { + const inner = (t) => { + const p = checker.getPropertyOfType(t, outer); + return p === undefined ? undefined : split(checker.getTypeOfSymbol(p)).present; + }; + const mine = inner(record); + if (mine?.length !== 1) continue; + const found = checker + .getPropertiesOfType(mine[0]) + .filter((p) => (p.flags & ts.SymbolFlags.Optional) === 0) + .map((p) => p.name) + .find((name) => + others.every((other) => { + const theirs = inner(other); + return ( + theirs === undefined || + theirs.every((t) => checker.getPropertyOfType(t, name) === undefined) + ); + }), + ); + if (found !== undefined) { + key = `${outer}.${found}`; + break; + } + } + if (records.length > 1 && key === undefined) + throw new Error( + `no property tells ${checker.typeToString(record)} apart at ${where}: ${own.join(",")}`, + ); + arms.push({ test: records.length === 1 ? "object" : `key:${key}`, parts: [record] }); + } + return { code: "arms", arms }; +} + +// One encoder and one decoder per object type, union and array the module +// reaches, named by a number in the order they are first reached. +const coders = new Map(); +const order = []; +function coderFor(parts, typeExpr, where) { + const key = partsKey(parts); + const known = coders.get(key); + if (known !== undefined) return known; + const shape = shapeOf(parts, where); + if (shape.code === "string" || shape.code === "number" || shape.code === "bool") return shape; + const exportedName = exportedBy.get(key); + if (exportedName !== undefined) used.add(exportedName); + const name = exportedName ?? `T${order.length}`; + const coder = { ...shape, name, typeExpr: exportedName ?? typeExpr, where }; + coders.set(key, coder); + order.push(coder); + return coder; +} + +/** The type expression of `parts`, `typeExpr` naming the field or element + * type they come from. */ +function presentExpr(typeExpr) { + return `NonNullable<${typeExpr}>`; +} + +/** Lines that write `value` of `type`, named `typeExpr`. A string field + * named `file` is written with IrWriter.file, which notes it as a source + * file the encoding names. */ +function encodeValue(value, type, typeExpr, where, optional, field = null) { + const { present, nullable, undefinable } = split(type); + const coder = coderFor(present, presentExpr(typeExpr), where); + const write = + coder.code === "string" + ? `w.${field === "file" ? "file" : "str"}(${value});` + : coder.code === "number" + ? `w.num(${value});` + : coder.code === "bool" + ? `w.bool(${value});` + : `enc${coder.name}(w, ${value});`; + if (!nullable && !undefinable && !optional) return [write]; + if (nullable && (undefinable || optional)) + return [ + `if (${value} === undefined) w.byte(0);`, + `else if (${value} === null) w.byte(1);`, + `else {`, + ` w.byte(2);`, + ` ${write}`, + `}`, + ]; + const missing = nullable ? "null" : "undefined"; + return [`if (${value} === ${missing}) w.byte(0);`, `else {`, ` w.byte(1);`, ` ${write}`, `}`]; +} + +/** An expression that reads a value of `type`; null-checks are statements + * through `local`. */ +function decodeValue(type, typeExpr, where, optional) { + const { present, nullable, undefinable } = split(type); + const coder = coderFor(present, presentExpr(typeExpr), where); + // A scalar read is cast to its literal type (`17`, `true`, a union of + // string literals), which the bytes cannot spell. + const scalar = (call, plain) => + present.every((part) => part.flags === plain) ? call : `(${call} as ${presentExpr(typeExpr)})`; + const read = + coder.code === "string" + ? scalar("r.str()", F.String) + : coder.code === "number" + ? scalar("r.num()", F.Number) + : coder.code === "bool" + ? present.length === 2 + ? "r.bool()" + : `(r.bool() as ${presentExpr(typeExpr)})` + : `dec${coder.name}(r)`; + if (!nullable && !undefinable && !optional) return read; + if (nullable && (undefinable || optional)) + return `r.tag(3, "${where}") === 0 ? undefined : r.lastTag === 1 ? null : ${read}`; + return `r.tag(2, "${where}") === 0 ? ${nullable ? "null" : "undefined"} : ${read}`; +} + +/** Field reads and writes of one object type. */ +function objectLines(type, typeExpr, where, kind) { + const enc = []; + const dec = []; + const fields = []; + let index = 0; + for (const property of checker.getPropertiesOfType(type)) { + if (property.name === "kind" && kind !== null) continue; + const fieldType = checker.getTypeOfSymbol(property); + const optional = (property.flags & ts.SymbolFlags.Optional) !== 0; + const fieldExpr = `${typeExpr}[${JSON.stringify(property.name)}]`; + const at = `${where}.${property.name}`; + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(property.name)) + throw new Error(`field name ${property.name} at ${where} is not an identifier`); + const access = `v.${property.name}`; + enc.push(...encodeValue(access, fieldType, fieldExpr, at, optional, property.name)); + const local = `f${index++}`; + dec.push(`const ${local} = ${decodeValue(fieldType, fieldExpr, at, optional)};`); + fields.push({ name: property.name, local, optional }); + } + // The object is built with its fields in declaration order, an absent + // optional field left out where it would stand: an object whose keys + // follow that order (IrWriter.keys) reads back with the same key order. + const literal = [ + ...(kind === null ? [] : [`kind: ${JSON.stringify(kind)}`]), + ...fields.map((f) => + f.optional + ? `...(${f.local} !== undefined ? { ${f.name}: ${f.local} } : {})` + : `${f.name}: ${f.local}`, + ), + ]; + dec.push(`const o: ${typeExpr} = { ${literal.join(", ")} };`); + dec.push("return o;"); + const declared = `KEYS${keyLists.length}`; + keyLists.push( + `const ${declared}: readonly string[] = ${JSON.stringify([...(kind === null ? [] : ["kind"]), ...fields.map((f) => f.name)])};`, + ); + enc.unshift(`w.keys(Object.keys(v), ${declared});`); + return { enc, dec }; +} + +/** The declared field names of each object type, for IrWriter.keys. */ +const keyLists = []; + +const body = []; +function emit(coder) { + const { name, typeExpr } = coder; + if (coder.code === "object") { + const { enc, dec } = objectLines(coder.type, typeExpr, coder.where, null); + body.push( + `function enc${name}(w: IrWriter, v: ${typeExpr}): void {`, + ...enc.map((l) => ` ${l}`), + "}", + `function dec${name}(r: IrReader): ${typeExpr} {`, + ...dec.map((l) => ` ${l}`), + "}", + "", + ); + } else if (coder.code === "array") { + const item = checker.getTypeArguments(coder.item)[0]; + const itemExpr = `${typeExpr}[number]`; + const at = `${coder.where}[]`; + const enc = encodeValue("item", item, itemExpr, at, false); + const dec = decodeValue(item, itemExpr, at, false); + body.push( + `function enc${name}(w: IrWriter, v: ${typeExpr}): void {`, + " w.length(v.length);", + " for (const item of v) {", + ...enc.map((l) => ` ${l}`), + " }", + "}", + `function dec${name}(r: IrReader): ${typeExpr} {`, + " const length = r.length();", + ` const out: ${typeExpr}[number][] = [];`, + ` for (let i = 0; i < length; i++) out.push(${dec});`, + " return out;", + "}", + "", + ); + } else if (coder.code === "kinds") { + const members = [...coder.members].sort((a, b) => (kindOf(a) < kindOf(b) ? -1 : 1)); + const enc = [`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`, " switch (v.kind) {"]; + const dec = [`function dec${name}(r: IrReader): ${typeExpr} {`, ` switch (r.length()) {`]; + members.forEach((member, index) => { + const kind = kindOf(member); + const memberExpr = `Extract<${typeExpr}, { kind: ${JSON.stringify(kind)} }>`; + const lines = objectLines(member, memberExpr, `${coder.where}<${kind}>`, kind); + enc.push( + ` case ${JSON.stringify(kind)}: {`, + ` w.length(${index});`, + ...lines.enc.map((l) => ` ${l}`), + " return;", + " }", + ); + dec.push(` case ${index}: {`, ...lines.dec.map((l) => ` ${l}`), " }"); + }); + // A kind the union does not declare writes nothing that decodes. + enc.push(" }", ` w.refuse("${name} kind " + String((v as { kind: unknown }).kind));`, "}"); + dec.push(" }", ` throw new Error("IR codec: invalid ${name} kind");`, "}", ""); + body.push(...enc, ...dec); + } else if (coder.code === "arms") { + const enc = [`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`]; + const dec = [`function dec${name}(r: IrReader): ${typeExpr} {`, " switch (r.length()) {"]; + coder.arms.forEach((arm, index) => { + const armExpr = + arm.test === "string" || arm.test === "number" || arm.test === "boolean" + ? `Extract<${typeExpr}, ${arm.test}>` + : arm.test === "array" + ? `Extract<${typeExpr}, readonly unknown[]>` + : arm.test === "object" + ? `Exclude<${typeExpr}, string | number | boolean | readonly unknown[]>` + : arm.test.includes(".") + ? `Extract<${typeExpr}, { ${arm.test.slice(4).split(".")[0]}: { ${arm.test.slice(4).split(".")[1]}: unknown } }>` + : `Extract<${typeExpr}, { ${arm.test.slice(4)}: unknown }>`; + const test = + arm.test === "string" || arm.test === "number" || arm.test === "boolean" + ? `typeof v === ${JSON.stringify(arm.test)}` + : arm.test === "array" + ? "Array.isArray(v)" + : arm.test === "object" + ? "true" + : arm.test.includes(".") + ? `typeof v === "object" && ${JSON.stringify(arm.test.slice(4).split(".")[0])} in v && ${JSON.stringify(arm.test.slice(4).split(".")[1])} in (v as { ${arm.test.slice(4).split(".")[0]}: object }).${arm.test.slice(4).split(".")[0]}` + : `typeof v === "object" && ${JSON.stringify(arm.test.slice(4))} in v`; + const parts = arm.parts; + const armCoder = coderFor(parts, armExpr, `${coder.where}|${index}`); + const write = + armCoder.code === "string" + ? "w.str(v);" + : armCoder.code === "number" + ? "w.num(v);" + : armCoder.code === "bool" + ? "w.bool(v);" + : `enc${armCoder.name}(w, v as ${armExpr});`; + const read = + armCoder.code === "string" + ? "r.str()" + : armCoder.code === "number" + ? "r.num()" + : armCoder.code === "bool" + ? "r.bool()" + : `dec${armCoder.name}(r)`; + enc.push(` if (${test}) {`, ` w.length(${index});`, ` ${write}`, " return;", " }"); + dec.push(` case ${index}:`, ` return ${read} as ${typeExpr};`); + }); + enc.push(` throw new Error("IR codec: no arm of ${name} matches");`, "}"); + dec.push(" }", ` throw new Error("IR codec: invalid ${name} arm");`, "}", ""); + body.push(...enc, ...dec); + } +} + +coderFor([moduleType], "IrModule", "IrModule"); +coderFor([functionType], "IrFunction", "IrFunction"); +for (let index = 0; index < order.length; index++) emit(order[index]); + +const lines = [ + "// Generated from packages/compiler/src/ir/ir.ts by scripts/generate-ir-codec.mjs.", + "// Run `pnpm generate:ir-codec` after changing the IR's types.", + `import type { ${[...used].sort().join(", ")} } from "./ir.js";`, + 'import type { IrReader, IrWriter } from "./codec.js";', + "", + "/** A digest of the coders below: an encoding written with other coders is", + " * refused instead of misread, also after a change that keeps IR_VERSION. */", + `export const IR_CODEC_LAYOUT = ${JSON.stringify(createHash("sha256").update(body.join("\n")).digest("hex").slice(0, 16))};`, + "", + "/** Writes `mod` field by field (codec.ts describes the bytes). */", + "export function encodeIrModule(w: IrWriter, mod: IrModule): void {", + " encIrModule(w, mod);", + "}", + "/** Reads a module encodeIrModule wrote. */", + "export function decodeIrModule(r: IrReader): IrModule {", + " return decIrModule(r);", + "}", + "/** Writes one function. */", + "export function encodeIrFunction(w: IrWriter, fn: IrFunction): void {", + " encIrFunction(w, fn);", + "}", + "/** Reads a function encodeIrFunction wrote. */", + "export function decodeIrFunction(r: IrReader): IrFunction {", + " return decIrFunction(r);", + "}", + "", + ...keyLists, + "", + ...body, +]; +const text = lines.join("\n"); +if (process.argv.includes("--check")) { + if (readFileSync(target, "utf8") !== text) + throw new Error("codec.generated.ts is stale; run node scripts/generate-ir-codec.mjs"); +} else writeFileSync(target, text); From 6434bbf33b53e8fe59a96873c696ee60439b9a82 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 00:22:23 -0500 Subject: [PATCH 034/193] Pack jammed saturating loops one copy per lane and test their exit every fourth iteration The jammed loop of jamSaturatingLoops now tests whether every accumulator is full only when `j & 3` is 3, and keeps the inner loop's own bound. Past the iteration where every accumulator is full, the copies' ORs leave the accumulators full and every other value they compute is dead, so running up to three more iterations changes no result. The copies' statements now alternate in the jammed loop, and each local's copies are added next to each other. The vectorizer pairs neighboring values of the loop's header, which were a copy's real and imaginary parts in mandelbrot; it now packs the same value of two copies into one vector, without lane shuffles. --- .../src/ir/saturating-loop-jams.test.ts | 17 +++++++- .../compiler/src/ir/saturating-loop-jams.ts | 39 ++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts index e25b17e80..25320b7a6 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.test.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -162,9 +162,24 @@ test("jams a constant-count loop whose inner loop exits once its accumulator is expect(breaks).toBe(1); const last = inner.body[inner.body.length - 1]!; expect(last.kind).toBe("if"); - // Each copy keeps its own accumulator and lanes; `post` runs in order. + // It is tested every fourth iteration: `(j & 3) === 3 && ...`. + if (last.kind !== "if" || last.cond.kind !== "logical") throw new Error("expected an && exit"); + const fourth = last.cond.left; + expect(fourth.kind === "bin" && fourth.op === "===" && fourth.left.kind === "bin").toBe(true); + if (fourth.kind === "bin" && fourth.left.kind === "bin") { + expect(fourth.left.op).toBe("&"); + expect(fourth.left.left.kind === "varRef" && fourth.left.left.localId).toBe("j"); + } + // The copies take turns statement by statement. + const firstDecls = inner.body.slice(0, 4); + expect(firstDecls.every((s) => s.kind === "varDecl")).toBe(true); + expect(new Set(firstDecls.map((s) => (s.kind === "varDecl" ? s.localId : ""))).size).toBe(4); + // Each copy keeps its own accumulator and lanes; each local's copies sit + // next to each other; `post` runs in order. const added = out.functions[0]!.locals.filter((local) => local.id.startsWith("%jam.")); expect(added.filter((local) => local.name === "%jam.b").length).toBe(4); + for (let at = 0; at < added.length; at += 4) + expect(new Set(added.slice(at, at + 4).map((local) => local.name)).size).toBe(1); const block = body[1]!; expect(block.kind).toBe("block"); if (block.kind !== "block") return; diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts index 96973a483..74c204fea 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -329,7 +329,13 @@ function matchJam( * elements that answer NaN out of range, and write their own locals. `pre` * and the inner loop read nothing `post` writes, so running later `pre`s * and inner loops first changes no value. Independent iterations then - * overlap their dependence chains. */ + * overlap their dependence chains. + * + * For the same reason the jammed loop may run past the iteration where every + * accumulator is full: it tests that every fourth iteration (`j & 3` is 3) + * and keeps the inner loop's own bound. The copies' statements alternate, + * and each local's copies are added next to each other, so the vectorizer + * packs one copy per lane. */ export function jamSaturatingLoops(mod: IrModule): IrModule { let changed = false; const functions = mod.functions.map((fn): IrFunction => { @@ -378,9 +384,13 @@ function emitJam( const own = declared([...pre, ...inner.body]); const j = (inner.init as IrStmt & { kind: "varDecl" }).localId; own.delete(j); + // Each local's copies are added next to each other: the vectorizer pairs + // neighboring values of the loop's header, so one copy per lane. + const renames = values.map(() => new Map()); + for (const id of own) + for (const [copy, rename] of renames.entries()) rename.set(id, fresh(locals.get(id)!, copy)); const copies = values.map((value, copy) => { - const rename = new Map(); - for (const id of own) rename.set(id, fresh(locals.get(id)!, copy)); + const rename = renames[copy]!; const rewrite = (list: readonly IrStmt[]): IrStmt[] => transformStmtList(list as IrStmt[], { expr: (e) => { @@ -428,11 +438,30 @@ function emitJam( ? full : { kind: "logical", op: "&&", left: allFull, right: full, type: BOOL, loc }; } + const three: IrExpr = { kind: "numLit", value: 3, type: F64, loc }; + const counter3: IrExpr = { + kind: "bin", + op: "&", + left: { kind: "varRef", localId: j, type: F64, loc }, + right: three, + type: F64, + loc, + }; + // `(j & 3) === 3` + const fourth: IrExpr = { kind: "bin", op: "===", left: counter3, right: three, type: BOOL, loc }; const jammed: IrStmt = { ...inner, body: [ - ...copies.flatMap((copy) => copy.body), - { kind: "if", cond: allFull!, then: [{ kind: "break", loc }], else_: null, loc }, + // The copies take turns statement by statement; the exit is tested + // every fourth iteration. + ...copies[0]!.body.flatMap((_, k) => copies.map((copy) => copy.body[k]!)), + { + kind: "if", + cond: { kind: "logical", op: "&&", left: fourth, right: allFull!, type: BOOL, loc }, + then: [{ kind: "break", loc }], + else_: null, + loc, + }, ], }; return { From 85fdf4ce742606ab42c3012c2bc7ce11c0ebf8d5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 00:40:53 -0500 Subject: [PATCH 035/193] Trim the per-chunk work of compiled object-mode stream callbacks - A typed write or transform callback whose encoding parameter is a pointer union with an undefined arm receives the null pointer directly when an object-mode write passed no encoding, instead of converting the encoding value through a dyn box (scr_stream_chunk_encoding_undefined). - The stream completion callbacks carry SCR_CLOSURE_KIND_RECEIVER_FREE: their thunks never read `this`, so compiled callers skip the receiver bracket. - The completion thunks borrow the stream from the closure's box instead of retaining and releasing it on every call; the closure, alive for the call, holds the box. - settle_next, entry_convert and clear_buffer_each test their early-out conditions in inline wrappers before calling the full functions. A corpus program covers the encoding argument in object mode, byte mode and transforms. --- .../src/backend/llvm/expr-stream-callbacks.ts | 42 +++++++++++++++---- .../8520-stream-object-encoding-arg.ts.json | 6 +++ packages/runtime/src/scr_closure.h | 3 +- packages/runtime/src/scr_stream.c | 33 +++++++++++---- packages/runtime/src/scr_stream.h | 3 ++ .../corpus/8520-stream-object-encoding-arg.ts | 42 +++++++++++++++++++ 6 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-stream-object-encoding-arg.ts.json create mode 100644 tests/corpus/8520-stream-object-encoding-arg.ts diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 3d9d8da76..a90764464 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -279,8 +279,6 @@ export function streamDoneFnFor( host.declare( `declare void @${entry}(ptr, ptr${twoSlot ? ", ptr, ptr" : valueData ? ", ptr" : ""})`, ); - host.declare(`declare ptr @scr_box_get_ref(ptr)`); - host.declare(`declare void @scr_stream_release_v(ptr)`); host.declare(`declare ptr @scr_error_retain_v(ptr)`); host.declare(`declare void @scr_union_release(ptr)`); const params = [ @@ -295,7 +293,10 @@ export function streamDoneFnFor( ...(twoSlot ? [` %bslot = alloca ptr`, ` %sslot = alloca ptr`] : []), ` %capsp = getelementptr inbounds %ScrClosure, ptr %self, i64 1`, ` %bx = load ptr, ptr %capsp`, - ` %s = call ptr @scr_box_get_ref(ptr %bx) ; the stream, +1`, + // the stream, borrowed: the box holds it, and the closure (alive for + // the call) holds the box + ` %sp = getelementptr inbounds %ScrBox, ptr %bx, i64 0, i32 5`, + ` %s = load ptr, ptr %sp`, ` store ptr null, ptr %eslot`, ...(twoSlot ? [` store ptr null, ptr %bslot`, ` store ptr null, ptr %sslot`] : []), ]; @@ -347,7 +348,6 @@ export function streamDoneFnFor( } else { d.push(` call void @${entry}(ptr %s, ptr %ev) ; moves err; borrows s`); } - d.push(` call void @scr_stream_release_v(ptr %s)`); if (errT !== undefined) d.push(` call void ${releaseSym(host.shapeHost, errT)}(ptr %e)`); if (dataT !== undefined && isRefCounted(dataT)) d.push(` call void ${releaseSym(host.shapeHost, dataT)}(ptr %d)`); @@ -868,18 +868,46 @@ export function streamCbThunkFor( host.declare(`declare void @scr_dyn_release(ptr)`); host.declare(`declare zeroext i1 @scr_exc_pending()`); if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`); + // An object-mode write without an encoding argument passes + // undefined: a pointer union whose unit arm is undefined holds it as + // the null pointer, so that case skips the conversion. + const pointer = p.kind === "union" ? host.nullableUnions.get(p.unionId) : null; + const undefinedIsNull = + pointer !== null && + p.kind === "union" && + host.unionsById.get(p.unionId)?.arms[pointer.unitTag]?.kind === "undefinedT"; + if (undefinedIsNull) { + host.declare(`declare zeroext i1 @scr_stream_chunk_encoding_undefined(ptr)`); + d.push( + ` br label %etchk${i}`, + `etchk${i}:`, + ` %eu${i} = call zeroext i1 @scr_stream_chunk_encoding_undefined(ptr %s)`, + ` br i1 %eu${i}, label %etok${i}, label %etconv${i}`, + `etconv${i}:`, + ); + } d.push( ` %ev${i} = call ptr @scr_stream_chunk_encoding_value(ptr %s)`, ` %et${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %ev${i}, ptr null)`, ` call void @scr_dyn_release(ptr %ev${i})`, ` %etbad${i} = call zeroext i1 @scr_exc_pending()`, - ` br i1 %etbad${i}, label %etfail${i}, label %etok${i}`, + ` br i1 %etbad${i}, label %etfail${i}, label %${undefinedIsNull ? "etconvok" : "etok"}${i}`, `etfail${i}:`, ...(hasThis ? [` call void @scr_stream_release_v(ptr %sr)`] : []), ` ret void`, - `etok${i}:`, ); - passed.push(`${ty} %et${i}`); + if (undefinedIsNull) { + d.push( + `etconvok${i}:`, + ` br label %etok${i}`, + `etok${i}:`, + ` %etv${i} = phi ptr [ null, %etchk${i} ], [ %et${i}, %etconvok${i} ]`, + ); + passed.push(`${ty} %etv${i}`); + } else { + d.push(`etok${i}:`); + passed.push(`${ty} %et${i}`); + } } else if (isErrPos) { // destroy's error argument: `Error | null` — wrap type-directedly. // The finished/pipeline callback ("e") may declare `Error | null | diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-stream-object-encoding-arg.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-stream-object-encoding-arg.ts.json new file mode 100644 index 000000000..5c024208a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-stream-object-encoding-arg.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8520-stream-object-encoding-arg.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_closure.h b/packages/runtime/src/scr_closure.h index 89f0378af..ccf3bc529 100644 --- a/packages/runtime/src/scr_closure.h +++ b/packages/runtime/src/scr_closure.h @@ -95,7 +95,8 @@ void scr_box_set_ref(ScrBox *b, void *v); /* releases old, owns new */ #define SCR_CLOSURE_KIND_CHECKED_ADAPTER 8u /* a checked callable adapter: the callable is in caps[0] */ /* The body never observes the ambient receiver (the `this` an unbound call * binds), so compiled callers skip the receiver bracket around an indirect - * call. Set only by the compiler; the kind proper is + * call. Set by the compiler, and by the runtime on the stream completion + * callbacks, whose thunks never read it; the kind proper is * function_kind & SCR_CLOSURE_KIND_MASK. */ #define SCR_CLOSURE_KIND_RECEIVER_FREE 16u #define SCR_CLOSURE_KIND_MASK 15u diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 872586976..1f07d167b 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -541,6 +541,7 @@ void scr_stream_release_v(void *s) { scr_stream_release(s); } * the stream. Returns +1. */ static ScrClosure *scr_stream_done_new(ScrStream *s, void *fn) { ScrClosure *c = scr_closure_new(fn, 1); + c->function_kind |= SCR_CLOSURE_KIND_RECEIVER_FREE; /* the completion thunks never read `this` */ ScrBox *bx = scr_box_new_obj(scr_stream_retain_v, scr_stream_release_v, scr_stream_trace); c->caps[0] = bx; scr_box_set_ref(bx, scr_stream_retain(s)); @@ -752,7 +753,12 @@ static void scr_stream_do_destroy(ScrStream *s, ScrError *err /*borrowed*/); static void scr_stream_notify_finished(ScrStream *s); static void *scr_stream_read_n(ScrStream *s, double size); static void *scr_stream_read_n_kind(ScrStream *s, double size, ScrEntryKind *kind); -static void scr_stream_settle_next(ScrStream *s); +static void scr_stream_settle_next_waiter(ScrStream *s); +/* A for-await iterator's parked next(): only a stream with a waiter that + * is not mid-read has anything to settle. */ +static inline void scr_stream_settle_next(ScrStream *s) { + if (s->st->r.next_waiter && !s->st->r.next_reading) scr_stream_settle_next_waiter(s); +} static ScrStream *scr_stream_alloc(const ScrVt *vt, const char *cls, bool has_r, bool has_w, double rhwm, double whwm, bool auto_destroy, bool emit_close, bool allow_half_open); @@ -989,7 +995,12 @@ static void scr_stream_call_read(ScrStream *s) { * multi-byte tail held pending) adds nothing but still clears the * reading flag. */ static int scr_stream_add_entry(ScrStream *s, void *entry /*moves*/, ScrEntryKind kind); -static void *scr_stream_entry_convert(ScrStreamState *st, void *entry, ScrEntryKind *kind); +static void *scr_stream_entry_convert_kind(ScrStreamState *st, void *entry, ScrEntryKind *kind); +/* An entry already of the buffer's kind is kept as-is. */ +static inline void *scr_stream_entry_convert(ScrStreamState *st, void *entry, ScrEntryKind *kind) { + if (*kind == scr_stream_rkind(st)) return entry; + return scr_stream_entry_convert_kind(st, entry, kind); +} static bool scr_stream_add_chunk(ScrStream *s, ScrBytes *chunk, bool front) { ScrStreamState *st = s->st; @@ -1395,8 +1406,6 @@ static void scr_stream_next_fulfill(ScrStreamState *st, ScrPromise *w /*moves*/, scr_queue_microtask(job); } -static void scr_stream_settle_next(ScrStream *s); - /* One pass of createAsyncIterator's loop for the parked waiter: * `stream.read()` (a _read below the hwm, then the buffered chunk; pushes * the _read makes join it), else the settled states (error, EOF), else @@ -1442,7 +1451,7 @@ static void scr_stream_next_wake_job(ScrClosure *job) { * error rejects, buffered content fulfills (one whole entry in from * mode, the whole buffer otherwise — Node's iterator read()), EOF * fulfills the sentinel. Called from push/eof/destroy transitions. */ -static void scr_stream_settle_next(ScrStream *s) { +static void scr_stream_settle_next_waiter(ScrStream *s) { ScrStreamState *st = s->st; if (!st->r.next_waiter || st->r.next_reading) return; if (st->errored && !st->error_emitted) { @@ -1964,7 +1973,7 @@ static void scr_stream_rbuf_to_dyn(ScrStreamState *st) { * A Buffer on a stream with a decoder decodes through it (Node's * decoder.write in readableAddChunkPushObjectMode); an entry of another * kind turns the buffer into dyn entries. */ -static void *scr_stream_entry_convert(ScrStreamState *st, void *entry, ScrEntryKind *kind) { +static void *scr_stream_entry_convert_kind(ScrStreamState *st, void *entry, ScrEntryKind *kind) { ScrEntryKind want = scr_stream_rkind(st); if (*kind == want) return entry; if (*kind == SCR_ENTRY_BYTES && want == SCR_ENTRY_STR && st->r.decoding) { @@ -2390,7 +2399,7 @@ static void scr_stream_writev_batch(ScrStream *s) { } /* The buffered writes, one at a time (Node's clearBuffer without _writev). */ -static void scr_stream_clear_buffer_each(ScrStream *s) { +static void scr_stream_clear_buffer_entries(ScrStream *s) { ScrStreamState *st = s->st; if (st->w.buffer_processing) return; st->w.buffer_processing = true; @@ -2412,6 +2421,12 @@ static void scr_stream_clear_buffer_each(ScrStream *s) { st->w.buffer_processing = false; } +/* An empty write queue has nothing to clear. */ +static inline void scr_stream_clear_buffer_each(ScrStream *s) { + ScrStreamState *st = s->st; + if (st->w.head < st->w.n) scr_stream_clear_buffer_entries(s); +} + /* The fs streams' clearBuffer: nothing while constructing, and _writev * for more than one buffered chunk. */ static SCR_COLD void scr_stream_clear_buffer_runtime(ScrStream *s) { @@ -6698,6 +6713,10 @@ ScrDyn *scr_stream_chunk_encoding_value(ScrStream *s) { return enc ? scr_dyn_retain(enc) : scr_dyn_undefined(); } +bool scr_stream_chunk_encoding_undefined(ScrStream *s) { + return scr_stream_writes_values(s) && s->st->w.cur_enc == NULL; +} + /* ── the readable surface ─────────────────────────────────────────────── */ bool scr_stream_push(ScrStream *s, ScrBytes *chunk) { diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index 578712df2..2ae392258 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -556,6 +556,9 @@ ScrBytes *scr_stream_chunk_bytes(ScrStream *s, ScrBytes *chunk); struct ScrDyn *scr_stream_chunk_value(ScrStream *s, ScrBytes *chunk); ScrStr *scr_stream_chunk_encoding(ScrStream *s); ScrDyn *scr_stream_chunk_encoding_value(ScrStream *s); +/* True when the write in progress is an object-mode write that passed no + * encoding argument (its encoding value is undefined). */ +bool scr_stream_chunk_encoding_undefined(ScrStream *s); /* emit('data', value) with a checked-dynamic chunk (borrowed). */ bool scr_stream_emit_value(ScrStream *s, ScrStr *name, struct ScrDyn *v); /* Whether a dyn for-await step (readable.nextChunkDyn) answered the end. */ diff --git a/tests/corpus/8520-stream-object-encoding-arg.ts b/tests/corpus/8520-stream-object-encoding-arg.ts new file mode 100644 index 000000000..8ef57ad7d --- /dev/null +++ b/tests/corpus/8520-stream-object-encoding-arg.ts @@ -0,0 +1,42 @@ +// The encoding argument a typed write or transform callback receives: +// undefined for an object-mode write without one, the given string with +// one, and 'buffer' for byte-mode chunks. +import { Transform, Writable } from "node:stream"; + +const seen: string[] = []; +const objects = new Writable({ + objectMode: true, + write(chunk: { n: number }, encoding, cb) { + seen.push(`object ${chunk.n} ${typeof encoding} ${String(encoding)}`); + cb(); + }, +}); +objects.write({ n: 1 }); +objects.write({ n: 2 }, "utf8"); +objects.write({ n: 3 }, undefined); +objects.end(() => { + const bytes = new Writable({ + write(chunk: Buffer, encoding, cb) { + seen.push(`bytes ${chunk.length} ${typeof encoding} ${String(encoding)}`); + cb(); + }, + }); + bytes.write("abc"); + bytes.write(Buffer.from("de")); + bytes.end(() => { + const upper = new Transform({ + objectMode: true, + transform(row: { n: number }, encoding, cb) { + seen.push(`transform ${row.n} ${typeof encoding} ${String(encoding)}`); + cb(null, { n: row.n * 10 }); + }, + }); + upper.on("data", (row: { n: number }) => seen.push(`data ${row.n}`)); + upper.on("end", () => { + for (const line of seen) console.log(line); + }); + upper.write({ n: 4 }); + upper.write({ n: 5 }, "latin1"); + upper.end(); + }); +}); From 511bf04cb9b68ae301d23a29487240e489c6760a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 00:57:09 -0500 Subject: [PATCH 036/193] Refresh the runtime profile The string spare, the main thread's idle trim and the shared-bytes writes changed runtime functions that hold enough of the committed profile's counts to fail its staleness check (7.3% against a 5% limit). The profile is collected again on this change with scripts/refresh-runtime-profile.mjs (327 raw profiles), with the LLVM helper built from this tree's sources. --- .../profiles/linux-x64-gnu.profdata.gz | Bin 89083 -> 67691 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz b/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz index 3bc3ecc28448262364cb5647b5e2019301ffb1ea..5d3fe0f897b5738fb02df60760f079bac6e14346 100644 GIT binary patch literal 67691 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

qr%-kBbkmp!@3mwas4-{s0 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 R>Sr?2{|^*M&Vl$@1OO7Mzv=)0 literal 89083 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%0A;tyJF_a}%exuJ`+@SFw4g(0 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

J8L7_-dO3In!%~hY};4g_oC5uCxS*DyYooDPX zBfLV6(B_tC(vYBQ8Egu9lM03g@IeI%<_(|QK-eD*zq+uVwev}IT~w-QSV~)nwX6BYAu?F0c8k4F2?Xhf?7@w-W_M+*U9w!# z565%KANQZWp5({^!~t+&G&Ug+H!xH^p#u;j@ZDlLd(PAOrgf4gh4gd^EL3|w)lgh! z)ch&hh6GFot@E!6jd)u9!S3pxuUWVfkaQZ{rmYDqH<5$r>B47!pX)i95ZdBLyl1cF zeXjqz@XNj-w8bSv^*g!FpdOFfasXkLYBVwumLLi-3VA4vLs9V(6}VaCVTPB2y?baV#_RLSyT9DO#}?H({BgE9%l?j| zZDC?!Rh#@4o}&X_pj{nZ0H$_7oZ*RZf>AtgcLHA}TXp~b?&Rfhf84NUf#39bW&-Ag zHEKXYaAQuq!(-C)tF8N0pNh6ld}$%Xa0Din&p3Lg+QAHC6AosL!p4U+*Oghu9f7FQ z?F+jn)QcL9u;w~~vaWUn)nY^=rE-zD2Q_{{#*p)BB)-ukYv%Ku+khFatO4vFzcvTV z@4!}IS_4e+O;NzdyN4LoD6~}42B7f#sz%)nuRi}-xG)4vl%Q#b*U0x1Z`}(9Js2|h zboFMHs)XGf2+rZM%(s!n&g%!D=|OTKR6h3g#oc}D6;4aE#EbD-0y#pBltoIn{x zX0?t6y{jGepn@uDafe+Hj3JLbbKtG-_T*CQgPpo(-GBsO*J;MDc6H13?+lMQiY!|h zt}_ai1(kMq_X`_uxYMZRp9q=nVCBDuI@;6?ZQcea8@tuHGwLk5a zrj_mc4z3nm@Mm>a=@f8axK04k!0fgsTGI^F0WAx{E@I(Eiy%MKZC3mFt%LC&)JtBmF|4YKWRcY@q|NQ|O*v2vK09U6cM?KkE*5*VqPDuo{PfHvcq9$wdb z_*^z_p-r5iBg^aW7Bl=W{axIQ2I;94(myqdabO`i86_gQP%H_hwO&Pvkq))|Pi!96 zRu3(A4!;QG@O z66wZ3-c04*c-|7SqO%EdP2cVp=aJ*P$L9aka{9L)uityUeYzV97D_KC?aF`(RS835 zl`7ieeq?lrTuNe}?wr*(Gq06$aAxKzH&*nzBw&_IuA&%jr$kDLAFYmWQol%MY(Vsh z^v=uUD3`DVnm_9Iwc)6qC)g}W)M6!oeUf83A+unuw7Kh{eYX- z=yD_T(sR~Ed~Lq-#DD>aPqwg3wmD-#qVm3-wxW}tI&7TbBXBd`hw5Xy?}K}3)GhFT z*kqhhFpxmi5VDW#1>QkEFR~X%i^l&CUu=o}U;F|MrtSCs;}k#ksN7iwqbXh!NtJXE z_Ap62E#|vj{J~DscmB+2d@R>>f8Q?tkhMWW8~d-Hmv;77-a0l33`5uEFl<9>x|WKT zkD$7FOqreLk|t1N+9oZ2^ZD)2Ek5YN9*0fp9nQxYP|65Nmjteow7t*OE;lE!llc7HQSwDC5oxaJ+=^X471+ZF<0Ptx z=^NvZED#6reSgz*K3m}aTfTp;r5>(!>i!=?K{}-mA6D~zucHWpm|9?x;|6f1I;VQSX0DJ-W8@yW1n{`&W(Uf9an&ms*l93w)u=9ok(>IR7Hk80`-}uS>U@ z$q#aiGT7ALX!0pkrIxhHmm8d7bLw9y%tc4KqFbu}w|mE8EZJOEBHx@Wa_twKPFA{i z+qSVrwr8#b3n2(Sroq4fVT~H-o76aSxcLnMYW@5m@L9i>YnYazc^-ODMBJ-VcW_x+l1A74!lW$e-YNwR@aXVG_qwx8GA)~JSo4GZ6ZqPPACAb_ne z_Q-a)x~U4M=IT>B?Qlf1Be~AP&e3PqG)CX?c|ob8-&8tnmWCGpG$s^fT)D=|B1AH= zl(7blq!@0lGTk(9ap3a)#ty%E_x4V6|CgK+Ulz4<9*bzC;K8C<_79($+W)3@YY*f7 zpBf*ZH~2-3ZAGgp%|JR2yGa%%=Br*oNyUp=)r+4U{6mlAqn+l6`E*)KEU13{a`<|M zKP=Iy%((+4e?ZRuwkbkTi?V|jx+Ajr99xI3S?7>ym8L2Qi>nGoyQKE=@iEWigVd2~ zo$uf`_*m#sZ$>-(c@PY>+uBi|^?8%|;50cN1n%AY4#Yi*UpS4E7W3?8%KR|9Js)EQLLI;8T;t=K)FkPS2hpTYR_bp zjCLZX_@i^rmBTcwkWq-hY6}HAaS_M5p2`a7^E%c^V-&nniqbUtsH82R8}zN3tZ9dw zk&N%FzEDTEpQko-dwjRihRcQ+s4B1w5*T?PLyT}4Xur>zIhQf_qdH<@C2dZ&-)b`6 zZ%|icGfB8!z+=v6MEX=IMxGDqKcv^a00ZaW1R;c*Y<}rG6kC)l#^|qdllf`{65(~4 zs-A3t%xNmCOU%*QW*+ZMj5v_v@;YkZW~@wm5&g4TOqQoD%XUE)GvSnql8tzDZIBzS zO@_9~z9f(zg%cLXaoM4nm^Rqhv)#lN_1GNrv&{od63%k{_<%N_HQ*48ys*=C6=rU{ zoRIgu*%${F?g4#uLJxDmYA40fXUBnSAIv<6>UMWC#TaW`IhnMnvdN$OfJ zIgXNP{9}87Psr9=wrrO6YE#)#@xQn%T@84%(ihj?aLhr0K=NVo3aS;_OhH>!DU_;@ zxqDkYTAHY5ud%6F6FfDEQ2Yb~gp|siiF#(i9F1)%%n`O_NaTnZh$Ft|+Jv*xUDqyn zJi*(ZW$utG1R8e>k(bsDUUz^!66~zO7yI3}PP*m6!k8sOLyHx(<%r_{vG)~lQ9SMA z7aZLsD5Zi8Vj*9)!4|u_3j-Si5xct^TmPTknK^ccUGA*! z`@X+F^ZDq*-0n`#GtWFp#Hn;d$1$NTGIox#`&o_7rW^)xlPWFI?R&mustb*2#lzHj zp~Fu+5?Wgruf076jY(t>`#l`KKS?)bLH%=fF8(Z%Q5ItsiCRsa^iL0{r)p_E$e=p;yQWIy*4k~mS&Sy(-eOxx8h1k9BCmFt=EyxTl)M6yFqu} z;%Xu@K3_~YZG@_Hivi)GC0H;vZAcZ8Br>cy6$Ey)x7)-WG=wd}3Ju5dTzh5OnrTbS zo<2H(W+aquX9ntvlq+Q~TOPl=@#uOR#KJ^ef_hlK@~-84I#bkQ-()IZ4fV_txL8N| zVGzD64NkN3P)Qc$k?0ovj$qJ3AF5nj_E6SHcToz&Pz(Ot3d@IL&Q_75RrKCa>^ob- z!kSq8?Og(Nv;S1UGyR{iBaD92_WyYIe_td@Fr$F6PI45$v-^!Ogk7y zY=o|LD8)eX?qeJo?DVbi_O#n4kJo;c9sSJVWn0-Y7Jm318^%ml>T6|SF;a&S3_p+1 znx?rKL$;EVDPXYgYsP{+Qa`T+c@OAbP#N-{k&)vA%m}e6wlea$TeEW7W)wg6?kwAl zMWYZ)l-dL;YPnt+3&UhG{2Jq-w7KxM|A01k(-P%#(EGEROc>St;F7rkEZo%mi&C6Q zrdJTu1xBmWBym|)qtuY|&O7g&eWe)t)(gbsZSjqmmG<=g~g^+>QldX;S9{;5?;#U~Qwe zHE30jT*x|(#I8WC0bHnpyhkiP!P^B)$)|r}6~-c(2PgeYDEK|BS2M=*f3SE|==qD# zDur4Rb;ip1(+Tw(r7uR>gC77037f$~vESpN__jf;e5Byty09MTZT9m-@l^H0jg zWrTc_b(sM3bwBl9{#>6^Cv+;e|wG#>zd%f7`Kvv9wnGl4`;!2>;s?w~_e5dpe{|kv|LiyelYvuikZqu?lRBh48!QNQU{_^nWcWJro%ePWp zBHg`Ngh+V2m{_?|MtPFiRWoow*s#6^uSSUS*?~1Xf&X*JAAK+2tsE~}Jg(U5;>B2a zM{rc>(IX+LIF2a&?mvt@83+^ zt_2Tz4Q=t_UAI?uEJtaKmhvx>qCbSD?yA2dr74@rGo+TkksX{IzLFvCq+>pr9dN?yZ#Q z1(0p_=;QGwZ<`R_zj%AGF~RmeD?2?eV7o93-aEa?7=L@A8RLiAMsIY@|oj@ z-t))EcvozkZL#ym75g4vpUtrEP;gr_6TL_CFg#|DiDh{lc`gqebuoP7%CX+;jqH~y z9PPx*D`kWR`&B%GuOBOxbxXh239)M%k_k}IM{^GQP2c!_b?rKt zh(>Hc3|~xV{}AU%VVZ|0ig-QKc=7 zKTYMkpIF1E(YU|hY-d`T^j=$7Ugzfb7^lQBi~((f9m0w*JH{cscaAyZ@|kcB zv>1u=cW;4c0*+OS=1it`U>zpWiVg;J*fuG}`sU{O=uPsK8`L-8}p31pIF4j3+tc6!eC#0c7_anhnJeJE6DcQZ|YN=ft*cp4hf++qP}n`C{A7{qLZA z)K3pI)*d4`?c(;yDb8C)5Gn!UHnPe}e8VzbiN{P%v1EZ6F1X;1d*)TCG z`bfhx9X9BfpU%~VAXzCCciOTN#&BxloHU&Ckmt@G5zZEPI}hxpn(cAvj(KVn5z0%b z>=N+qoVWQ)ue~hprIpG!#&G{4V(;Qjfp=TjQ7HOsDgv)x&s_U~D`ck38Grye)Z#jF8VLWUY4(#|jaqBZyJX9D6q}B+l`>N8gCTl`i zqLQgk!VK%N9G;EOAPxC-f9L-*j55h1aHi0zEmk?YS32Yha^6w8>O#+D>P$c&uP})K zDOZprgZ?+nX5YzWrf`blU0k%@mPQA1T2M>_@gedAfzpLoI|{BKB$R7`HIH(Qz+r8EO}aij?}+?}D#hPCH3!?3*cwS~nu`%qy>1ZrHK<(6I{% z8g2U}Vj)2N(Jw`~>$mo;ocwUo?{76x=PaZ$O(gUH#foLwNNTQPPXSowfld1}eY26{ zSDy7oRjzwXhW_QqPdE4p;x~v_y2#Kiet&0N+l{^}_JO@g`0doDDq|K(YXDs>JQ@N} z1&vYz(s;Q-8b-v&BHYC6=FjHQgNMAW6ZzlS-Bl1nbvC>%BcNB4;Kb$YG|H#o)`DZ1 zxQ1M4ptZkelfo3>UprC2d9T%Lm}wjDVs>Qc+)=CQ2F^gvKBP*E zWC2n`SW)Afkdl+9J*{-tU0h@I7)bH>z&3cmuV$n(e8=ek<;NI?Yb5QKuy`uGTX#rm znKsZ-(ydnv{(P^!M|#BqfsnEbiVYS*sr`WIm*t?jzV*NyB?O~W5wq!61c!E5K>!}} zfl@w~c(J%LiQrFLG1f6O0^DUlLxxA_->2Cs!=p96Rh$Y-4WzOxCBRxC|F~3yrWHX6VkTq!H=?iMv7=a9(J2Mci z?NjJEGn5pnS84mBy2#h1Pk8tzH=|R*1Y<(f9F~mGF?;C6?wAd5dy|Ob(s8x z+HKJ5)57cyd~S+nNu5J*lxz3ssUM7k{5oCkuinI0}m=Poc8ZE}q$$$M+cdLcV(iEkUZ#NCV_{ zVEXvDSTCQ7w$M34;B#Ofy5)eka<%T}@fDHI)(gM*vL2ToBksP9;RF>B#LWdEEk1&} z7PGGX54Mn&gMye@*TS4PStvdWIHu52Z&oBlHm0Lis5_L(b^z>Bck*R5ClalnS6Joj zIKtcDhg4tR(=0y>a|lC8WYf+(>W@oTNQQ3w1CEM6VAGEtsX^R&2j3@!l4;*5+93{Tzq3KK2TJ0M_DN&YN zBS|vRUlfNRfKEvtd{|s?ya}gc(pZPj5R9FJs9HQp7`H}g1lY|}$4q%Go6;(1>GizTWTrLZ8M9A9df1#@vsEl4) z*fCLLq%lAqUQ5f6h8RCfvRKVBluMdr6l^3WbU~x&BVR9bAP&w5O?yEN`@5s1Ko{O& z%(jL=wLS2y(0NSH$|qR3Hb(4<($U;lvr7T9{UfSp$PH$nn)M?t z7rbJ=UUP`unma4>m)mm-Z_|mgBMuMllra^fB8TN$s!V!ewbnJxP@JNo25I}S$n!H=6JI6M1^CE4xq2$1A-v18A( z8tFT@rhPDj$GajNjK1ONRrl+h(cv|ZyT+onI=+OFBDNp7dui7Da zSE4xS&(Z3`n;%}6Vu1S^(m1{Wvo87|>d_hN11B+QeEPCMna{ku-A=HY-&&LlC+!c~ zf%z|)V(5^)?{C@Y0Hcql&&^9)W?vQ@*}N{Qf(epoo}g8jQrmHy%eUG>(JD@n##^>U z{af&MyP{@g+AghhiYZ(IIe+Kbf0I_5tu@Q!KVJSo(mxNZeMw`}7}c%8XHy|%d2V2D zjVd@42{gni5#T+P)DWTxsu!R5)MAf)`V}nYLNvx@U1+!Cvhc59FU~{%Zc<7`{6$q(bDj=KGEjRFKX39&VG~oQ#jFz*}VdwOs zs3DN2Zgw4h5q9EZ8vtssfH^>=K3ZiYiH0e11((?7<$QY$M8Ugtw_w&)5AQ92NWY;U z0>)&_HMn@=Ro86Ln#HvVgN&#BRKZp6KvTUwMK4}#Z*}xWxEXS62G5{hoTas>btvLJ z*iJyVl|hnCvyNbN4Qh%}CibFz+M?21dTa|F+wGAQ81upuagm2ZJY7b`rjbNTN zE4`jtr$ivGUd?lg@#21 zZ!uQDUvMT64lT-(9JLBOjc{P#a2RQ3wqBzYd<@ntrOV!mdhzx3t+6T`%%AvlCDL$1 zg~5buiaH^$NCk6qP&5Zbf&L{e*1kd>ueA^yXc9upc{i*I8WUef;*4NefDN2Gt!g2D*9gB;%Eo4qlq*9IV>7<{hIWoqi_ zBQcKOmKnQL?Z*g`Ireu}IY%J6d zX|d-)7Jnl}Sb)K!4W02LVLSdaSFA|ciqwHG_1i}27Zz)L+a_k%1&86ejx?&fNOFzs zo3xlS?*YG+z4d(WjUr-RAS0gpFJB%v~Db#M-}pdmE#J z=$iX%Yc-|>$dh{~QRb_65XO>C>SDKPBQWLt&&Y?q4|6_{5>MwuaKye236FSZ6>WoG zVvT5vFAtW-BJ}3oXdNrw=@|S$!*Kz zXiEK+99TQcX!K8Yu9>Mlwa>Edq!#04D)T*?ju|-;V08m@ArdX#bviXy=$8i^76`U zF?@84FOFHVS#^kIeFKn63zm6tw@vThXYEat?c94$V-_gar$=&{(!8~?MWUz?I~H*Y zOa6X15fss5)G*(vjpbeD?l^YSv&H;N`f$D3&HTzOO%1&4x#%SHP%KY^0T=wpq93nA zS2X120+KF|tx4x5ku_tzT^1ypiQeQCQn>pem3!JBuyt-1Q?uI3zLmHYRP-?$>m?#b z=JLDzjMk6$Af=wVp(cUZxe0N!G*`=WZ1pSL_>Ut$j=t|4@8qlh1QX%Iikr(F&;w_l zq@3piMCqdZFnxewpWNjv0aicVZy%P{5uUhKR9?0M0X5}vrhDF>HRqpOr9%GWYI{1a z@CrfL=kid50VI%_lNRRxb$<}YJ?#9g#W4&<3t+j7@`}#7(e@0$guNr%nqOn6@|nNg z1RMQaI`KcAaw}`LZ-LyMM0l}VSAQ|G3F^3eaetqUfkCokpP0uP-AHF?%{bLg?Z5+i zpnw>SRI1{yF$A|gT00&-3U0aFYR8A|T{nC&-+$1}V|NW*TU?`1&e^KTJ5GWI^Zt17 zn5^dWdJGjV=BwU+ z_g!``KV)s3P3%yYe#Dg%nS}00MhK+c6_U3$-5N=M_J&)?i~HOk)28eBl-_Ht&iUkO zdyg~a!54aXf4;M)DND}yXG1$p0AL*`R14w<_zGSz%6PF}nDSW8L#FqsUH-5G9Fa?) zPi8!2ta~SYzFS@X`J8_2>bAcscJYiOA3t3Z_LV%gwThh4eV(nby)>p6ro@MJPEIvz z{&cyj2fu$>n$vxjJT(X(#BsjX(1^+T5OKV*+Mk$iI9fXZGR|UgPc-d+DHnuH!fS9> z-=i)~POZZQt-P#U7I|iDhzc9D_4A7Gbn2dPon3wJcu;Y5D4dvtLj@A`WTW}?A2Wq^ z^!W=tPc3-rdQj#qm7M0gja_JFLYw{A>uH*~fLw`YH?%16hW( z&HaTmT(QWl?YL8KS>dV?`MZ7j1CPN50AT1LB;}~s-_*tTa|_RBvs*^dm-FxP)A_7h ztx=e3RZDp9(O&5Uu06GQ9<_&H0TvrdKu$yBo(6On{qA&w~(L@ICHe&oyV zi7R(cABnVXG;)9%n}Lfd!+HbNmL39x8-<(=zrEJv zK1bv^$m@x5`>L&^*F2fj{}3Eb{piba+K~?84J0t zdWLr6hPoNq!M5>qq1!@W=*D&`V4^t^WbN3)sN;OgpzYfUpA=5h-$_JTJSRU9b5;69 zb6cdR$96Wv4DjEySOrNEMO{y=F$I_YchTX!?eD0vsiA(Ji~rW@m1x_TTFDh?Q72Jd zeQRA%byb4l%c&4Ou{)sHu`;}l`?E&G=h15F;;ATsb0=5TGXMj#r&U0o)0znB?lb@p z8-O#++1AfZhG66m(DdwbiNTY+?h~kqDfvGpFO&-mAGr@`cD~il{WU=kf}Q}4N8Tp` zTQ87d$f)yX>}U&~f7cmYSB&lE91z5hAuMmvucJe!Et!FcA`FC@7|@TVivx|{f7Bn< z_)#*O7!fxTeK`!cS*V`d>9>CM#T_V z*(G5;OSjw0(Zn4Q@o0>L!c|xk6O|BiTe23YvUxwpKQa2#SnXVoPT!7Xv!=a6{4|8U zdpXj@vcaJtB!0_mgNSL$(~(=ay4`etw(wzfnIc9%v@6;{zbbL4ATkuX>Y38O7 zW$OD*osyggsA$vCPY7eNgh&S`!j6j zc5uhFGpW0&JDprqM_%Leh_uRXIVKr5UCN78)3|uR?^8WOOb&XFCt0JV>Q|ZbEB4b! z{jH!xEBW6K3MEDA|Hq*(aT4v|Gop5Io!e&R{FmT8WoId}`w#m6=Sty|J=wN8pVF-N z;wB%ZedX&Je!PCzBg1=lt98ghR7Z>-q5he35cru*|({a{T`N#u&_+Z*zxb z0A@*ri%@haO$$&qgo|8)+Yl9Qt=?Pr3mbiB3Jza0nNmn;m%$%=kz?xhScw`=C87vq z@o^WeFf9ZUrN&MT)S{UFU&l#;_*m&6*ObNF%OF@g%mR-B027&Fzh793e~%zgol_c? zw$Ane+oCl?Ur15hO*-P-8R zN4~38#}zn!t!NMWkfA^klvOofv#O9n6vwp$iKT7w!?}uN3!m2ZZLij`ig(xYxf|;I zr!U(F1+k4fZuo+|^`gC3coCp#ZVcMJ*{T4)(hEj?Vu&N#?}4Xv!RJTAwtaSalnbjQ#{dmh%6h7f$Jep$n{9H?O82p4c6`snySH10(ZPl-0Y`o+;RDzP1@n zo7wa5YJhd2Api(v0n~C1C_EY#d>S0_y8DsjkEj@Q-5$8;=RZH5_OAWAm*6ufLkzF> z_;|^g<3yS2Cb-RMLXwaIGjM02k~Lls;^M@%eTTMEmP9L8xiig7(~=XOiiNSL5=HEk zT-c}Tx`=#|E^bXWMoLl6bEs^mM3CivUrmG6eI>&u#lspW{RAnTX?Z!t0o02Zvx8 z`URu_H?i2_JUAWxe;qytk;wD&n1w3l+|i7r-|_YZQ)X z1PPWdHdQ%WNc!mBivRFgK{t3Jgaf)x5rY4-3SWh>*6Vs!$_H_pK#Rq_$#BUmUKYFc zhQZA=av8<;a*qP}`E2~%X`}J$wro4C7KJnoDp`^!1{<{(7vUEVSa7Y(9;*NvXr%?K zgpw`R(3~gR=>7qYT{LQ%&6&eeX$qi;K`)hW4GmAMN;awxE+G?hW!6_5`iSRaM(qj{ zsue=KI|$$9K>%GAI2sEi6oeb+7LcM^{i|$_d2aQXRos2KgjWlWiUpe{z@JW80C2dK zBPOAX_PXt8i?M5)4l{e2F;dVN#>ByaAagE?rF$dAF@x>XD1Hc5ICOQ;8R_0T>s=>GEb@?1q z^EZpOR`%CZbqB!|T#8<^7)R-VE5=Er|%|g z)U1UW8jKE#X~aEsTxT>9b-ch2AzD>SNYy&2u?KID6EaR}HuSitF_xI*7ItKLSU&Mr zsxW|!ykRsXe^O*|Xw_#b?fz)IRAE@XHwK^_Z?=JEEPtYb;LFp*@Gy9>SW^c0@JJ>e zaB8FVIqy}c?1Kq-tiKf)JWVq6p%DM0fkDFnWWrKNNQDhQ?RNOGv}+2F7c)`lIae zbiLSZNV%wg5Was-C${Dr{E)M1&%;AfuTMyApD2>mV|#HD>%w+*bwt}2$FwxL`(jAs zO58GSty8QJrd6#_5!O>f0PU*s-H&o%4R_lVq^$e>|oWXi99OJ1zUko|V z%`tbv604moP6gxSH;bnA2kG2g`vHribwxFuqZ-C{I+VcKXm>rE&`P{a0gC1X&MpS* zaB%n%l15nci=K8_%6d9u5Ci`aynK(z-dndpnHmu7y&b{?ux{zh)X9dsrcu%A2}PVT z4OXI9Ba4Xe^^6$QYIk|hTCrm%j!tFO##1KY8_X{6lk8>3H#2 zOj2PR)R|^T1Z{CcTj7&hOm1St+?~8{reb?61(d_l3qwxP=xN z4JWKrhE0qNSP!rlyW4=y_VDvjT8bqy@6l+dZoWaphC7bT6UJ^_*kFHT&vCsQ!`{e; zTL&h>yaka@;k>!W`o}o$^u_nYD+q)?Yz4yr!A->q+ejmpU*rw{!XJ3{ii<)4)n^dS zDd#*)a$sy{pwI*M(7oK^^v&awN}6o0d91U4JnX4!Qm#?5I-x3c-NM#D;>xRM6v8p_ zyEmt2xb(E^+X0}K7cA>8`GY|lPZR$XPO$8|PNG^Kdix?k&2yJ8i;f)W9jeQ-4-;p(C~NjJLX_`4D&DIVj{}gR#A$ZvNV)VQFY&v zX(To8M}6Kx4^|c7*NxE3>hj$Yx#ZUObdZE9A+NcP z`(Zr8?6oxgwq#nz6PS?VoA?Ob{;?onzlZRw*o}K&(?UOWqP%?+!`|C>WP2suZy2IV z)@T)0>;2~jUJN17>K>B|a=(Ins0YL({jI8v<4jI_Gl0U0G>qt^Btd;}7k>N*x4kq3 zF;92$kp8=!rLTyeANJR~&*M6RtygkM(c?`_3r%6r?JpprYJU0FpkTfLa`jp7+{z>w zoYlFt0FUZy1G9f6H$M`GMi4A)DWI-524(}~_+?tn4UPZDEUuOb@EHJeO$YfdWYDr} z1ABIW6_)Q@*nyaMVHWKzZ_N_h(x~NpXmW|59oE^f{V1WtmnyLsY20j-A|_@bhv3Cz z0qZbZ`y`KV)s%RJiTImCqnkeSgZI-VpvJGx*A(3yR`%*A)7;`Zf5LhY_K(s?A7Hc_ zZsEILSX?}%4H#mIpfHEDb!b&b_KMdUy5(d|ZQqRwoFW7n(bjWxRpBH?p3yr0xa^ZTAP(Ztf(*hZ^WD@d02`y+wkbB@+T1tEcUx}3iwo7O#Z z9!B2(1HuS}bwDXevoJ|c5{wfAOcpoSH;7tc3NUJffIM*S9)954n`+$0^R0QRF)CCs z!;iB(@KD=dMl&xS&!NUhH)QL9ohb>PBoIr;@)lgITfp(jgdhj;((2EiO>VS0r&M00 zryDGzd)lON3Rk6w65a4l3#H1MEbeU>D<@uhA88vmX31g>L9VR4Fd0-m`4ix7*%&4| z^ZC`-V3@{Ly)~}-N75p93$yMJno9p|CQvZ9CQ~vqMtp8_Ga&m4p6W;c>P8jh^S0-l zF1m$9w7z=zymcjTbl*nHA;Ae=-!*p!UPH7B5tUL{L%0@v?TPFRy3HKY_|pwBv&B+b zzVdjgBYC?NNS^m>J^F^15|!)Z6SinfmegVo7sQYcxIg9i1rm8&&F399{?1k23*Zw! z-hci&H>uVw$Md6(p|zX!@7t`^zdp^Qf0b{6(XV^!E}Q=+&LsE-?M{ntEv3#I|I;&SB?e(A4;4yP|$GAWKCTcccvzNZ3m7+yDM3r)V zJPfO;uiR92@bP~42Fi&cCdaKjlprb*LQ+G?iaL}Aij$t?7m*?~kBWl9n|Su$ybXvG~`9EW9glF|UMSkM{?11+l$`qP!d9SHe2 z&~SQB!OzMmu%v1!@gVdgOS9gtM^>JuS9b+HzKOKPtsT@$6G=jW3*V~V7;e%Fc;T9O zywA$5&Q8k{x{xn_tC~{SpY`6MKMvevY$nPCCOCkV+XiSe(>3e*PP}%dRn7R{?z)v6)&@$_1%o#|E*wZl#&evuE)e z^zy<>U7>`!eL;;I(905ccz-l_|L+bQDcVUf|JNybh&ezz$T|_Y^Kn0r`s#|axAo^+ zFq_C4A--05{=9N{XPu(CqRrd7=+0HhSEyL4lPc_wP3C*n-2JQB&(R}yci~ePFx5$i z=f(FHHko}Z*Wo}Blc)x2*FoWMf@6%DI+xv46VJ7jMm`@F=5|h?dV$paC$7JjSTcmh z`;r=><35efq3gE+syXei36#YeX)Jy*WI89Jt^_(k{E^D;RxFT~;E^_zI zOm#A^hPe~-EdCnKPUNeRBCi)2WP)#%r5j`m*&QQ&V9+i77y&2}%{QHMo|cb#EsV?y zq2Aqkk1yo3%4|soP>)+9xwTx?`p!jMeAmy=K20}iR36KHFv?mB7C*1kd-bW7ERY9? zkco<*xSU$LKSTQOLl9Rx+}F0W<_lk zehlsdztRCwu!m(-KGhWQ@5T>O$WG6@9{c%SS*EO^@9>9SZiaka-v;EV*Ysy>XBk`( zj!f{odfF!ZxX#))Tc<&vH_jRDPO2KXF7FqHCok7t2N_y~4#G2L(t`wQ2hjb1-2*u< zu7j&Klqe+WG?iQBYR-a_!cT@v5QpK$*y`ycOw%ei`M2`71C<-P9QF*c5U%)e1*;?{ zPmFXNJ|2)J5YBDR#QHiclN0`aGu+Tetb&v(vl1(DOl;2V-hMV2_aPmARU`PCD(15jtku8 z>o2)<^e-;$AQ+Y!?6~Zsxw#oW|Ms4uEQKpQ#mGycsI-Tz{9K{*F}cu78`HR|3p-IV z`BARt;V7|JF)gXkHXk3?U#;z2!C@9zVw$649Rk&dncn$Hn~x8x7%3?W$sH-If`xDt zR!2O;%3fc&t<0Xe^=wi-{##QB14wyeiflihZ2zHVvjn4zXAdx~I$U(2 zAc4|a{!s4*VYA(4uoc}RiXO{`1kIG(hDr!O;SS`|Xfy6Uo)8k94!Y)g&!@L|o1{3R zssZp)qlk6ktc@nPNk-=nY2Zt_usNQ(v1t7cgqnn>M6S7LO&I#%)p{$S7|bt;@4kEg zhNK1U@(`QYi(7NXdexs+G*8u%%_Ae-c@Y0x`wgGtans|ET_OpkWWE1IdguVG!TCnf z8hA`ysMi_YAE8C}liMC;2poiR$z#J$IdlQ z!Fz8Amj#Z)gT47cZ7senrDw%aaJTFRZu&LU4K9782t{&lS_o3Q+rM!PGb6@ znX=quO$Mub5(y=8i3ynsBdvTZ4G!0Z(@)%0u%GU|qY6i)YAL}_RS+MN+^5%S7}^2H z&F@sZW(*+GhR;IFrSH%u5HTv_01a0rNmW4w?!1X z$zFg@^RWJ>uM5=hUGz_!AMl7XG^ONsgNrc@^(}m!c7Lo&H<7{7B?E+y9$ecxdU?Z! zISXbJfv1^3XpRwvU-hJkoJ)w*)kT{+O_OzxZtZ@PWq+7*4W4H4okX$Z1qm&j0~OMe zvULEujJW9I5EVWjjsjj9qgTRLMvtQ(h=ZWxJD-sweep`M`pF|_MW3XTQi`)u(*58! zT?+B?v!gUM6ScUA$}_xwo*hPSxA5yWUtgTsE?>JbqBR8D_p`*^XlTMoNUB@O>-@cZ zS8`n}DUL?6Vd#CATFF9!t2T0vu)5#PwDQ*T@r)-X7lB#J56jVa9`&hx$%LBUiO-i? zEpK2<^zu^pYaf@L08g$TP0CQShDUQ>Nc7hVjII9cGJ(d$WMHU7l2nCM0b$I>F*6v_ z!h?~<(u%DO5;ZpST)^|DfO{@Tn9jbIj(yf?*Q&ZP3lD`g36fsaTwE>vj~S6@i;0kQ z-d6l)gF3e>SXj^As37Ga?Jg$=-*!%Z9o&3f^33;xTMx)+16pwi z@R2(tB}9ozRum=U@G2+4{$iEx28{)m`WY>VsxI6B~t;~syK3mm^#>@6y3_msHDs4Eq3hoO1IR2 zoT;YDPp5gvoCKqnP4*T0RK%XSQ@*(i_g&kIguO&slvy!M2{LFfP9C%9R?O|6YP_4= zEBVAQ8(qw;sl~ecyG~U_AK~37Y$uKlx=;3c%qX4k2NY4OKm|Wa6{QGZ^?kkDy$Sy} z+TM1;+RdkGO*VOWsc6UHa-xrOsG3+6XT_|^YG1{}#8rywkUwxe1RR%`p4O3Mi4w{) z9eAgC8+V@7^?Lt-)P666(HFBg*xJ6cE)73G^Z#B?5YY@^eEoUry(B<(E_`rb=lbdj zR74VgAD!Hp5h^=4{?fh-nDcV+X(R64&Hr$vU0lJWbN*ULxK#^dWfVV}F&T$!WHZ7G z&ON+6R{vd{Nz7AX>(7V7nLb)#Zt1JU*t$a0q|h5K!Q#iqJHB~_o{1aledGa!58fdW znm}iiUj~Dd4e3ie!5ndW{rXIdRlav^*@Q8e^VwteVE~$R`x10Z>ql{ba*e}VBsgm? zZT^FCk{KrG%g`-YH+usNM!9E_2-6<=x)ahk)&+-XZ`foPn=Fn9{s1cwK^}eFXy*%1 z`SNtx!LC{Nc8t$0He&Mj3lyo@C#sa6i;y|j*liJTK?swnv#{HvDTNC0&~O!W*W{J^R`(j#O- zZ{aPDu=%+@2PMy6){`1F+KGWPm$N4Qb}wtLodIm{e&(|Ar{hlP)s&qUnR+rSNi=Lr zwTQJ9jgr`nX|48)b4W{Xt=q_foq+*0F7K%UNeK^-WFM@voh6Tw3Xg5?TEwPBMsSW3 zT;Rb#f*Kr)C|>y_rRiSoGdYsTv?`|oF^%jW>tuoZso^E$A!5$6H)JI9ODKl#6SiKM z!ug=6AlIcl>^)nA_cuBw+5 zMz^bTvhto`(^ov}9mQ~tz_nRXo1J5nRUpSGf*iUJC|hSb80_?AY7?GbbBSkG$Q*13 z2A_UP(fY{2QA#?rF>?34bMcI2Kp9RZB0>3&=*t0|;cr!DfMP@dz34o1W6&Hze?T{l z!JuS)3MlUKfVY_*RGxaX358iar$Ki3JMMcMrs;v+HMoF`^;9s9Xg(2_ar$lD9*b^< z#zV--QFQ*+yp{9`DQmLaYpCEQj%fN+wKtSrFu5NB)-r z?cCUI+weHj@-9856Kqbs9A?u5!c@`yzy8U&fyp$(V-KTaE`#I!Z>-4$0{&aC81h~a zVdXbS1lbd(U1c(@8eS>M4&=@rM;k5Aom4YD+)FYnEKyPfN%a`@ji#Pi?E&CNf^OJLeIfDMzX#7qk_^Y zP!8MK_nz}{V&~8t1ADvXeUHwu%rXD7)C7S>{fqv)sX?qu~E$ zvR#CQ5E$O5R)Uf+kH;wNWgq+E)ic3v4;oQw$n|Jfu#zzxezcW)8@yr&`6-MeY&P83 z{b-JaBwqYU)it{-;BM5iSF$p(=4g*h=2EG0dU+Pi;^jUsToP1;C9YR_PG{RHQcbPb zrSuI0Qq*(6ITE87Fo%|pdFJT=0&silq>!H(Su6%sw+2l!G2qRDfn-U*OB3BD1j?}}^{O8Dsm9VJPts6R8))SuV4F0J}Rd>hb z2Bx&Z3~y{7ft@+{a)Rb$UABn_H^)}saSK@mw8r^kO@XuceGRLe3_3u8#==n3_~@!W zDLscVhI-yw{DBDij0S;M_$W)Z$TzfrYr52hqq(XB(~g@XI+Wd7yevQEk)|$xO%6J< z^b%Tp`W}D#yXCW4UjHk?`nk58{8o6JK8k({U-JWW)iSTkLvy+E0n8pi4n|dJ=(uHN z?*gl!?o=EVsFc#&1rM)&2Pxl#(hx+ z`2S%pL7TQ^(L>@e7UdBgmZ#pcK{`9pcl*3o1OKk6F?jS$`U;MEPly%!DvZNE=|O64 z@6^tHdw*nV{t|=ys$-jx^Qk`-3i97P z#lX=Rfjr%bHs|w`?Rxn>nK6h;R-C`fkXS~N%Frkwy;<2r6aK&vq|@0%aBfG?`2<-F z8ev5oyuQ=(#pGN)YN>*57pc?n-vOQhq3-I`WrqWXtAbj-8)F)}ic z61$~E@|XpyweOA#8Y5LlGK?f?6`>|Q-i$v;?{YedjVq>6^Jg}b!A#BMhyo7uuDLDz zo+nTafU80AGAYYn)YS@24r@EIOVWKRs>kq>3xK?6UAHSL!y)zgtq>DwCEdnZg%*tztQB(tli&a zZPrmHRfClRqKWwv(vcsje3=Nlfa0tl_q;JDx@!^ym*@ZO{9CylJE7<7 z+V`5070zJpeCDCB+j-~qd>@lMuQ15rxEqms{3&`_d-UKTzuwT8L>^T-By)w$-)uKB zqqe4)za7mz^dJQpD>!u__tQl^RD45%%9H;cYCJf?NC$63l2@%2+k9`bH1Rs^Q`-IS z`&4}^pbp0SM~MkRTXjasArR-`LbODKf6!TN9*e4JmnsH2loWZC{``EeI9A1FaGrIAP;UW zZCZQi1R(=8P?Ah4BfGhLjmD*Y6lV@B2$S9A3?)bJVlznELi*J-Rd(YcQj%@ZsY%Rz zv$dM4-lXH_O9uI0zDw9R^?n_>7l)y8Ufk;0kwdVCrvJoyNRpl_FCCN{dz6!NaREPm zX?TU2rHhY>S=ylXF7tXN$%!?{Gz9x(mKD=A=TeQ@+^O;Fr}1D0Fn?Cbf@n6VmShE) zWN}nx<B%1+`_NB@?CUP^MC}go}oT zI12IbDGw?~#NWMrMlSR`#x>XJNI2j$6O~F<6Yix@B^CA;-dDeP@Wy27-8dW_e(e!F zW_p0V<^9~7ZHKUtvTmps8~%IAO2mZ~Km4I*k_>?6qr#2k1J3RHoFGf-ZHu5D2!ymG z=Pcd}MqZF`i736O`?#gKClhye?}35gvu8|+ASY!OPGTni#@l(VHT<)=46JPfM_DX{ zTy6u}+yQ9Lh9QB-u6@4yyE)!tDu;uQ=k)A0-{ji0QXjhjx|9k?a@gj{}#3P^`iPO3kz~F_AK4U|~xbMvRxM z(3SP2!!^3N1(%QScRL8M4zNDDd-rbrh;PoQ;u9)+T?W|x7ymxEx;B9RvfyS+a3pi- z_(@;4cz&vOhEXvvGgSy_#JP)R)$9sr0G-^0m=l=!1DBj)J?!q=`;2)Zq~LXe-t>ws z`~7cVAj?x9yiKwq+D#>(ZL8Z9kwWdxhrSuNIQzk{(t1M5rWKzQBA_|Dtd*+!RbLAS zTLM9vj2#$;_-s=A=sc~{iOykTc!=ow(@KXvW9Z9@59Chgah)@r!Xi zSgc4z(bciLG^wk$7RNX${HP1AJ7X#hBZF!L$(X=V}FL%;w ztQQge7#A=l^M#YB>@(KnwuFtXjj3UNIjDlZf=c(lw!2Eqo6|3*g^P|6z$)ZrM+@nv z@sQWJHW|+(dmcq@rXNhW#3ynX{DwdfO{{BXkXx$XF-=*gP^Zg&+wM)URh6=C^g zFxcV7Vh?MUg()*4o`s50KrHKD42l(NYJ2#XfSbS8z&yz1hEM!w4s6CETf1ZHyK5$| zfW)=Y)Hmy#+auXNgihL0g#z8rK0`&t!lN7UZWqIxzo`3Lfd!FaOoKW3GYolGpEqNZKAGN! zwwt%<@3aTg3%_Tq-)q+)n_Jh0~rvpgSANu-H#eDqyD44!YP<1$DACiYF*#{!S zUA<(~hd$93oCY8+N{&Ub7_us>=e=Te9c2g3QD`-$okC4`l7M*+iY|EDAIGlK-1VC0FVAWCo*gNUXQAYDST_SS zH+>%{DY$%-=fG?z9Y!EZRzkehW@zf2UA3dc9=^Canul>ieOK4tRv7>=#x6uS?3eMi ztH0=bryq3dU$u>oTShM!f>sy?Ck0FgX!GKH0H{@U#A^C~tGzj@y~hr&4u?+JJnmgI z3^H=<=fi26se=G`}4Nm|&CkzS98 zU8uToe<%kia+-427s#!cXNa1ti^x$2Py)aZ#f55KwHV`8+3|JDs((}pj@!HvC(F{J z3!t-zj8W)+S}W>Z&s=L42If>-K4J$>97eBiQ31<6_K&lWo3!Xm3O>5m%Pb zC$g^6AH(-H@_3U9}h=2Y4D^=`a<7kl3`zZ2Ke1mnC z7JA40PC!M`(%jrw*k@y~Vj#R6^^b^zN)-8IzZ`y=q^N44o(U4KP~s~5G{c|C&)WF; zkXkd`NbtGnBxSf)%0CX zdH8{HWo*)IV6tg&lVW&tOtqI})lYynZcJMIFyl77d?N;^3H=-|j7Od1!hforPFiyJ?0>iAp4iN+W55Ov(QM zZ9tO0Bpzfq4H=O5#rAit(0?|TG?z!eKjqo4DE*zp?bEu${3}iE-EGz*O+*ht;aiTf z$xu=Y{-mcOwI(l0SRbp5OKINP)J29tYZKq&{h{)WmG`IAbbeK|Bl^Yiem5CYO36oT zo#H2Xir@QGK}1|Q(=)UN$-1dJYO-H$(G->@d%ZlsRw+junO!vh0O~=i7S$i z{1LYPkjsFUSeC}jAE{uPo@&`IU|p2^V)6*jN8n2yIPTjc49MzAe8ZPjY=6uwM;=slzKI; zlE=eHO}tVeFMQeA=snwc7m)%*Uo$$vhHKNPWW=RpthSiMdvR!cNGEvbYKqJ)u0LBG{q z?9k*tzawGPg7VLLcQk_fd*%A>cH7f8j7{&3a-t(6^`N!U5=}v0(RZD=N)f{ zDjD&<>AGNgY?_6;0RwnP8}{28SB$m%wnRtHOZ5G|Y8;y0zBGkmwM3&OV{5~JiN}X7 zonm*mtPNUM$4opY(R-6IioR<`H3p1_>t+yy7O*KW${`QuX+;Xa3NwQJ!!D~V&!{wl~8lu2=~P&Hk=#07UsRI7hN5f-p~+S(Y4Ky z2}hS*9EgsVomI1STUHI(n$PMOl(%2=@oSG9PZm0Zw2qLflv0UK94AjCLr<&Xi4I}a z8~W1djd1Avs6sPO2E-A8fH%0=^u#g(NiL$Q8t@BeHcMnOu{2t*j9t<`YQMwuI(Gip zDF14J98(k5_gvOS#@_$gdVaM%_|OktcCPv`-Nk4&VI2}=0Udvw9{Ia`U zeALl#ejOrP8Tg}|%ZE_vG)WXH(kfI5(iT%*=GOjUj+Sr}sAhV__5lT_v9HV|;F>*SUPlIrK%i4@JwscB1^`3j#0LJfQK7!xp)&Wt2lV*LOU>Rss zWjhH-EFyiXGx8|a@N}WRk3G?w(O&dEf?Vla8$cw#^8q7Vs_hQe_t0|$PfcQ%3$&oQ zD<(T?X~X1qf9=EEIX$5@2|70~K~)W;S408(u3tdrNz2YYg9TF>C$CM`mLJu^AwLa&h6zarP;#_j~V)^C0H&L1$}Wz6%u)z zSU5>|y=Mi(@Ls?QNBQxvKim0Ch&Li7uY)e>V(*LSWZuE;J6RPG1H{J$)2iBwYS&6{ zZie5`GGBpv=d;r9D7YG{`>4AO^@|eb(eo;=-C4H)dXC@`SpE?c>%q%8`NEy`ef&LC zE=qY${eKGSD*rN4$H&E3%0K(Z1V!b#uidZ`T_|s1ju!L7e#J?2ghnn=$omtWE#6tb zedx-sfED}2lHJiX8a{j&e>T0c2Ic>%W!`%}vz<6JRxVfnxPJfL?-MZw2HNT+tz$(? z=FjMaJn__>cj<+%?aPX*;Pa^}|Bt7iifRsk`MOpUEmB5~+K3jd#CQ4oteaj{P_0Af z&2w`6XJ<;V|zoGD56Y$(1@Suf*puq^CqfxOfYIy6VgW?6pfljVRQ2ttr6Uul9j+ zyTby|s~45!9F3ScYV^tLlWM!LX|HjF7VM`d^h8Dfxm8clwAb!(=&+)^sXlcCyjgko z>-bD`@?|6Y;S~$os+;N(W7fKTaW;u}HnNE{s4zMd<3_KG3>7O>T5Xb}xfsyAesYD z4C1sluGodI4?b`1i*Q1EVb@$Xfei5dK`Luf;X?`tWSVh0#-A4we(db1V+~5NaO`ns zn55q(nO;H2#H2gSMJ>zjo;^3y4$*RUCBWVagTz`VM>W5SA+$#5M5F$dSrbH~3~oScP~#zxG8z>yF0OuQ3bx zjiCF81=_3%IjQFXB}#4j$6K0vbg;x(aDPf`G{0f#?ypuRE^EGqErt#yS`tgN;s||Y zB%#68c{h~7ckwGCb@N839$UAxI{K?!w8iLz}bOnB(lYEyWK5ej9`a z=>xWq-6X)CX5+4jzqPWHv3iW{fW`44yb1MVQOGHlzF^KIoPUQTV`&4Fy_TJ+>eOyRirOoy;{ z8Dd*px6{ED;9T4e#m7ivxD~9IcC`+7QufMbLpcT~Vk)JWP|BuX=@iPQ?}p>Se0;XD zU72w?u}O&)So&Uw$KiN6ksyweMrsJ+{ek%d=eF--g%8BZUsN9D_5`;oe#Fu6+_9g1 zSk@lqs7)!M!i$kM52g~PeBL_qev_-MI&C0Fj2Vw;6zKf9^F3@zRIhX9ruwzviroAt zugsEG!}%Ta39t3-q{<&B&)q~9BDx!TFfoWYM5-2qI9j3|p7Z548-=(O)-;FDqz}3X zn0X$J^F&{j0_@lk6*$At#@;ybf_6>$m1o()qyxnrgc)_JIJs0SmMdjMqF7qSW00qt zxv8$XgCjwUYC*Uc!pRVBi4^?OLBG*_XsysU@1%Zj%?I zS;05>%Y&-{xn?en<+&36JRF+?H1 z4kdwzJLcBXDTvl_8}DDYdue0Nfzm|3xeK06$$44(M!>@S#k9F=g=?O37j|$eMEXPp z9>O=I`3qONZ48cVXeM%5{}T$xjHfD4WOi;Km1CZ_+-n2SHiWE zpOK3=h^&;B6UKoPNl~8U?87so$OVn&wLhI zsuvo^R5J$g47_YRmS*Hp+Sbjq zm9pK)d&uj9x&ixhy!|L~z5lxj^_MGO=O-gsp!di; z&<#6ipiM)sat-?+-|^0ClWlZ|OO`^u;LGWsOLp$II(lmwSeo^tM%9xE{VHJ6 z5O#c~tTh{>83tioAjH8(vdnTAdFqNW*vv7S1fc}fUUMuIZ9B{hKqQ~p-%eP2&tmOW zW9_Yj(M)Bq_Lu&}_U|2>d}Uc=a3u1QbCzG&^&bS`AY*6~VpW2Ykd<#7v_I7Gi$e&` z$PMQgK+1(Bw_fZAh!gz%drv;EDCLi%hmq&M&L7I@v5s?%tpvCZ` z^5IM$j&d`;mn%LlYHD*f{+Yp90w4lRz3>Wn2SX-SsC7KegUws8fvhHjTu|29xM%hI z9iV%H4i2p4ADnrvPEMolW*#iJ3j@U>R;hJbalAw!mu;4njwj`6)+SynexI3p$iD3s z+b7S{*xbsJG6$(jp&%7446=um`k%U9_;Smjv0Vv{F$`zQTlW#nA97N1)ra|e!6`U0 zs)o;!weN5BlPkXEk@+#z zR`?H`Rf1(_B3k?ZwfEg|O)g8rY4qL{RKPBJ6dQ;gyI2t$DryK2AQ}=(0$7fU6?-p; z_1N&(dspneEB1D**vnDD%J+M+vzT4>2|>N*-uHg@$sd#1-gkC(c1ES6!AKa z_eW1pOW5-HO|~4L^)NQPoT|SggSW(8#VS;h%7iGnw)E;RzjKxk2qZeG1_shs) z-LGS-Ic%|7Yu;`>H^gc1^~_Z+l^AIj#L{KwRbdI+h{L&;Yma+u3!Uz0WV7RT4m!PR zvHm>maq&a(qOz7Db8%Pukn-K%<PKZ#-Cw;p7aOt50+fqL{A>S3C zMsPmdqnWCe>`)aF7WTvOs{CZVzBlouy}NHII_#f~cbsE}tS`A-9^mgM)hbk%?^>u^ zp7s}E;cO9%~;98>IuM7B?+zNz0#(T6t8Q>i8rB7!gnv{qoT>4mc%Xm>|5t8F zVuAs5{bgE-!xu}gV9}s@nLSN{mfMPSat%G>g}lS@?&Usz%eS@6Daqi(et&Yzu}kGL zC3k}>ja0|Qsa0mLPu*06Y4h>5Vw{an0FmI)q|$1YSBRw@gFDGx&1yMXT0UUbwC#Pn zSvXMF7daH839CxREc7SND8LSCv&8i19gDeKxz{7P50d+y zTzRQ(?~BdTGH;<4>T*{6>Gq0>mCv3-F{WqVihVaM(_zX>RPJM|9YbPo_y*v5Osm;> zu(kJFE31ECUXYAYd}~+U)O$mC1|6l)$Rl;tFF)J(qSX#_yv6$XkjEUK0vNaa_nA+> z1!Xp9{-iSN{sA#0xXoUgNZ&tPhbxUsmHDR7v*;T2!ru(N&RPjH2`atuKv{%Bn&=lm zuLR9`Ze*T43cdfn>DQ7a8HFXt6|F8xp^`>v)ZD9iWfY%^7yf2>;hR#Sv`sB#35rKV z-F4~TvC5R(HFBJ`&zjuN+-j32Zi6q>`Cz^#d%4wW&?E0@%ZoF1A%=ZIPRxDz?Uz-K z`Ao3HJJO60vQj<3^w-8V$;=U3c~}PmU(}4$Q}Z&1Ob@4nVe++bqcft0#@lGVox3>} zYKGzT=yLPTyq68L*W9gq|9sxNLrb0}RjW7-LxOz9qv(bmM3e23{>KUX-}k@ozk;31 zl9^H_WZBJndq6bu|5$ytib<%nFz#<^dIiN-TKrggg{{}Gs>jc^@2?Ke7w%`v{j>f2 zPq)jzsvUlHI)z2{_rs8=jB44nRyi<;d0;nc)arb;({m|EC;VYj#Ll5M+oqH6OH?)vB^f6p z^iT$*ukskA8ol}7e2->$bNJ|cFzU(OZlm$3z><$e%CYcO@+7HD8>vtvcbwCS69~l= zf8=ze_2`d&%MqJy8k$@EorVy&z<^6=I5n^U1!jjdb47~pao2?3LQ=H&f`05Hw8!9f znFJ=x@P++}c~C#B{B$OHI1_@(Dtv(-TZz20cxz4`5b~bNQ)>K$f=`!smuX=BwiIS; z_9supvH|dXL(dR@(Wly^uI?!|Has1_d_IE90Agb|^8rr-mz|)a%lDvhJm#=alo^#p_H1eMpp}b0&SOJ=HNs2s(c-dz8#P+ zW<5?S^8CAwaXNExjd%^Iu>$P(*|~ z2ZP6oiicr;Xdm2eHK_K-{ek1#P`foP5pTiMZWuN;C)O*$bgEwCsPrpR3oxEuxV#=b z`I}SWta#c}M8WUvE15kkrfc8qXlI^l0EJenN>C~l+I)69to?17GfdL5>A~eYT$kc8 z5N?nnp}a2wt4)2?@NsZl8AffhWoStKwU8hw6l z?x)SJ1_TMgwdE*U;H>{HsMj^=(UN>Rnm+A8+ueJaCjl`*+x#DrX7Qm^yW#%>hF!T` z&F$S(Tn@7<9k;EUP_+z;fsdsFI7WD3dEg>}x%ah!KCfRrVNn-|2M+3Gq}n98JloqI~y+G3Adnt_{G9 z6m_UABwwH5`#n6j7i`C%1>l26b)Y}-L8GRaJZP-N_b~u*?)acll%h59`jboz z2AuzaI}#%QYYjz2c{84oRvlLPlj#>LPnH+k)W!J?6p`hxLzNfruok(kQWJXJ$i}qt zYY~gUkJiGeG>W?mRBF%98Fso?%yhFJnEC@n1Sgh|y+`B!`~E%Oz5n&Y4}IU+DV7eS zdW0}}gR z*U|r2R$67#)U_q36gsV{!RuU32{WeqZ}dVnw7CH5)Eu;6wgy=830iRK*s*J69rO0Y zo4!*H7Gd(>w0RcwkQ`7<>}=a&pT3xh3)wGpTOXclXEeM>V4p31kbeG;p1(1grXZ|K8_344?MMsdC@_X#+zN%s%D3=ZBh3qc8^~$neTPLK>f-LwD0!oyNRT zUDt$1J?ww=%f}K8!U$;Uw_QN$aFB!d~kWM?L`%czuJ z;x96O2FLBtYyk|E(n12_uPl_e=>p?RmP6oqzo@5ovRO~cv4$ItT^L=os+map}|yei!Tuu6zw?`}UEEhDWfqY;pL zkd(Q^IF7gIBI$po2AQs90b)$+LDh*9=*7jj7?QQ7^yOmz>2St6luG(9hgEGm#BWpK zpCO8x9Y=8R8X?h+7_yi>1e*}5@%yZCmW@;GOW}5-m51Qw?3ykiHWl1o&P6R!hYt$P zapPey>RJ6*blcR|ZqC6oElSKfam0ziW9$=wH2>+}j>N>y-reJ(MP6yEP*7knzgenL ztf*4c&(_2O7mK?a(wcp4VXDp@%<75fp2Kt3g#&u$gg9^tAFcjHY((5>E*Zn63p;c- z!%E-`l@91FGh<>(Pw|n;9C~{1Pnr_;Ra_8@u|Yv)@Q&H;-1pD1PK+|hIUS3M(rAvI zQY|+(cj&O5QW4)s;8Qo!u#%*R_nu4X-*q$;vDkBGNl}*oXP&F82(^aO`obXHoA90m ziaAW57ZBR#jx)tSFnlW$t&l6DwA*{sR~hQ=Y1G7l2}}NA!Gr}3Pz-T+355#<#qX@T zw59xmiI}>&Yd}L$Dw#P}c)Ugtr{MH0xn~?+<$G;{$mc)glxNoJKQljzLY9l~fZ-Or z@jt4)t^Y&wD{Q&=+FK|n@hW_#S8!RZnx4>Vh3GH1f@t#K(je)}F}8SxuC)P8=df}J zb_aNG6G^4!!X-3jz%B7aG}B^Fu}OHeQn*1lJzPu7#w^W z#E`*+1eHb}O+PDjSc5o+`$eCeuSj16O@ldBRrLRfvchX1hoIYQeXyoNH> zfHnUkfYRP8awJ)QnK?43b@bRMN*b+D%2Ut3IaYMlTnC!cJ=d9p`E#hKF8b!o$4LuV zDs$06lskT+TM&91`E>RxPpVY{*ZpDB;Mobx)-_a2#$#46TG2ml@`u+S8|-nwo6o^) zG%oj6LNI5fkT}ktuZmi(?m9Geo7Ch)1yBL^>X1&wp9UaIbZJvtrG8K zH)L5lOD(1_*tu? zIOD5^GhA=vMnAtFF2BC-xJTPOWp9I_N6vU6!;sd8ky3QoQzQrVXyY`*ep`w1O@E8r zZ+ZGLTfl6Ef2B|Z!J}nG(UKFr6R{gp5{nb=_j+3sIQ~r4>+`(E@1ino>%qUJGD1S-fzU}S1ie6AJc?OBwA<} z3~u+S#MG@KJdpa$-a$|^iGUCK3lWU#tH_Z%tV}W$k{H>sU|Y zH`qFm!{4VvX&iy4y7o=bx#BS3yGxVtE-yn>YP3Pb^v9on4|gJ+>u`Q=yn)#@i7?x&w9P zh|BGN^vo&cM%RySj2R2c3>7M^T%%*DGu-QnGHK^&d|?m^Y9B2((y7nRbe2FLCo`az z{VAL=fZExO`aaL8`m_Y2Lu0jxMO>ILUtTG`?}CIr z8}N9(E9O1aaCT--Jyml5OE<;u?Cl1`^7uz1_Z0f+T$5&%sMr3}dfb2!DpW zrPHyd-0ANKPrle)wyv4c{Epl*|Cf^geEE0Frz{n*g7GN44=;u-D4gchSWx)x+}%$1 zmQOeHD`Yu)Is9wF_xeS*MLmB}`TqJhD+7uu;C@a>N$?8=b+*NQxY}{4ZxMr$Bl!M~ zS<3OTq)LTWCs)Ze+WHF>BX8TivN0>*m|1w$nstL~jNP;5`Ncd-h!T?K$`i-p%pwIZ z4HGS2GQqxq;D6u$k00L9Rz0{nzcAzAK4n3jwi3tp739ws{ytCoKbqdpesp~cTdtq2 z$B&k?pm87))OQ%sKWxFMmt`0i3QPqQ2`Vh}Fsp*!#g)BIJN&3}Jw84ZHspoVt6%lB zS@&-tGaY$Om2i2f9o5l%g}?Pddt7x156pj-Y-nL0k+4M7`cH3CS84c8AD6Qx?4ppoZ|$V9p>{OeKh zX@VdTNEOcN7X*ayQQ z=~V+ZD0aJ+W6dZ7gNj(Crv7rBn^loAP1Lt;n_g>zG`7D=p;Tqw=h7X6hg@qBm$=`R z#Rh?*aW^O2Wb&%8cDW(@qXCSsQ#`8Ha4sG1lwQ=-UxQUsk`I&nM@nT%jzOf0)!%$@ z=N@{b3wSSlIu}iD?-+Pfp2v3)5wlirE=j75=Tdg^Upo7v;In!C|8g2#to&!<=l1=- zb!HI>LSLOIGFs%>vjk%t8g6pY8udWAiW`vXBvaXmp(*W?!ft)&h>!Dzq9O*5niHj3 zWr8NYw)e&JoMq91C_4I$TK);p-TT1-tHHnL$%{bk)WXb*Y_g&1{Y^i>#AGT@lz@Yl@ z+b@kgI<3j-4yC-pC3vrq_1>w6cI|-q%_Aehr$)0}(2pHdzIC5IaqH^m*1oP8GFPTwasuNyxtLrh~%9J{+%a6MSi8W6gNI^gk-64 zqjtRSzs1_yW-GQ^aqVrd&d!f)j$ykMCp{J>PyRUS4{W!hL)s^C`p%CV#tcXCRpO!n zPrQ36=fKYR=47gK`*pLuuw9F*wzc29s+pbe9t1MCOv!p#F1N+S3qXQ+6*=@|i>2p4 zEWPlxdBh3Xn$GjEoeN2i=`B|lS+^D2xe&A{VcBX+F>h?=;^CZgmi@-`w!+F6;)-lp zF>2aN1-5f>a>(8X>(3>py64qhnzAx!+d_wF-UcVC{Bv7xg;tG>$LMYFCUqWl$R|rJ zw)w>iT6}>EU3h?JmE7>AX`al3m!UG22+qzE`6XT8PMwYE54-CiKUW@i z9aWyf)`yPIuSH09*3HS{&EKW*rM#gGD#!rh3onoeUi4%b?A_Pv|JlR?0xNO0L&*qKcBrqIGi3qPn-&a;lXuX`UR53Q@luu+Bi)L{4>t zmjV_+z{|ePjNOvZQl^zh1>^>;ago{A_{Hns)3WtV@}E-1m%w`n?Tw~`aT?-u z=x}s;u3|rCywt1LP!DfeML;@ToE~KwvuwN> z^OCmN%UOX%%Fd$xES6)>3C`0js2|n*@V!9FR}+e=uG)|1HBQUvr!X*0ix!W3+L3t= z+3JPPHTw?|zA4GrJAg}tnHj*dLgsmcK4sll^3_6&2MC^Km@KKh#;#EGG8QncpSMKD zr|%lxdTX24`1qep)m*8SYE`m$ZLC_?bEW_3WCOWRnz!6C8g)!av}w#*^$k~(!sBL) z_x8B;{8az6A`F@gy@S`tV>m)ge<@M)@V>))TdBBy?*Ir>{Wz)#DQhEP)d8AGrB~{8*F}m2^68HD+rxw^1 zU=TB(N$`K36Et9QV67f)9*xMOI>iLnB+p)6WUNf1o$I?UJvA&yf)zdKKB5J-(eFhF zr~-ei|8L-WG~kPXqJ*q45fQ#t8hb*#y^MCM1wF`><@Xh6xx6=BP*`UphA#`&X#+qo(`o~;^{e^NYq)SbFOy5saP{g7OYy}Ds{I^4!}D8FN1 z5wb%C>TM&axjWN`99|bDkna0nSnte4@h3pXOflGWjfYZZtI!Os-f-9>}@|FffLre=#iQ-YU}+N*3?k;J}4Pc2~@i zITd3hO$k`S`cJ3*bk;5rd#(7r!pXRs>ob3MXWS2w%oOhVLv$2UgC(or{Mq3kB^7oD}57^y!(c*#6Mh~25bK_+e0WA zp7G*(n%b{E zJUAU+@n5p~rwT|b>SAC^SyfIsJUOiP7{PY*9`@q7?swCpS0v;4IZ{$<;4Aq?9qv!v zhH>>C*6Y-?;0tJ!i+OVS?p|8Megf=J-R!-g$Z%@E^nmTu`j*u1B8#$j~(< z-J1=lh_88q3|3pMjJwuI8mRJGT&<(r0nhpIKq@&Fa9pE#P_7D+-H+q@dXW@aE|m`* z9)DwlBm^97Lxwx;K;j`3!A_@N&`0NA7wEOUj_ma4dNrc1(sE7Ax3J~Jq*=LyrdAhh z23SN>{KsG9eE&#!yzcvXi4#`jEbQnyjj{D_@pY^PEfayQ1X&L8C2&)3 zIItm79FNOq@0SSHH0S`{sU|fv%r@POJ^|YtfM1w0X zSyWV&d!t6FR^AronP30~ZWszE_x$rX;Cwn5kg-bH0Cj?n%jbR9kvEnN zIc$!1B77C66O+dcXNrf2he4PFiR0_2Tx-((3xy{_`g0|uJLbWb1b3{0wIRTZRJgeX zY`Hl8YfI7{<2ZgT6K^nt@|^?x!vm9 z4%Refq@vb^uhe^Rj4y@v^ z7$3OZ9I5>Ze7j12kGnbWbkIzPu#=Wmx_ICn z+fcZfJ5eE1Z=8Ti(EN9wdK>7KE9@igf*wKhjn0{me{*};>Dm(9CWb;jv{Zs#{t*P@ zr1PMtAv1qy{gh=(pL!Q(3>kw=fQHi_UudmarD5RShTNL*ih?azIMHs>y3b{cFsRY* zQbcMkdggMd+O7_L3{BU=SC)eOr?X}2+V&ZCHd~1ImwjTx`CzD}OD{sM)9A&>1)0N} zZQC+KgqgmlA`o2o8^vs2ik72I&(AT-@uC#kuV%H%*2AKXTL}Ny3{?MF6Udc#Kpmuv zb>`-xgnPtf6nl$`kg26^KcjUAsCrztDA-TW6DGUf%AP$ zo*~mMWu+vZ?-FYw3F!~V$57gmoH19%qKDB@)n*XxexeMcDS zZw6^7<6!V~lhWaIh8bQu;Bi^&cEU4BpDKGsfn~fIS2a^Vjh%S+F)X6a*Y4fUjBvBT ze9yJd8vIc$+iMHFfDsJW9*ud`KGGMLYUQJ8Hq%B&PQhB4=RS#gV}E@?6+G(4H_U#V z*0AFaA$2K%-<9JP`9^K>)Ky={)T&`Y<^P@Snx5ONe#G%6p?Fa{CS)#ZSj?Q$&~+v&Sy88PxS4+eojNA(u<@_` z=zM&0yQ8CQbO!=1wkb!1$KmjIbDR@1-jG@7&|}@FIx~NqE}j2`zx|m0^hf7c5X9-x zkS-5~JFk}FS@c%~Y~Izu`GBV@n>EUD?6RRP%5m!Aj|4_nO|f+t^Xe zdd$JnDix9PajNWBxxU*Z^g22|j-C8O%KhA`2WZA6qOm^=7OrC9518?%ECmcNA0xiTeKe%3W4r8HyTX3ZzWM(3v0&>dF}aaw5c+)O?aEZ!ZO?oS6TdK z_iZOg+P5Ku{af&FFA|_TRRYyl_<+?8Ki%&76p-T2zhiHW`1h;%et8HKD0-ou# z=4{IHX#XuYFo$~uyYKbHKF0yo>pKmfP;FZ22PMrh-D&1L5HVuPqdRE2;@BNS2g=>w zW+OoxC=VVN8pHXHuKA^%)}~BysPw**8y+Ghh2#MyN*<|{M#v-8aq>M9N ze)gMvRo_wbo0@iY8YM!7=-3Z2cS_DMGwCu6KR>zv`EHu5UGLxMTjkOWGKE@KwQ-za zvx0lF$yu_`XEgT-%A*DExqFMlq-gVJ(xP~g8$%f>ih+ZC~XWb#N z=%kiG6i-5EMWcNv8cs^mhBZL=y_zMA_W7-vWVo-x*b=!;A5rr;5Bo8RUArHQmpKRJ zTVG1z^oRX1#Pu>|XA%=T)7G;&pU!XIdmbOF7Uh%d)-%_l2B-+V28 zU+{nX!xN^)nasrhg!UBtm$au)up=No+j&&0V{VMa4&H@IZyKMUUD)uy{qr%n zS|m?HG`{cg@C$Eja=v;Qza7BFVD+c|NX?)|&fg4Q$4W{MAt3*B*q;$sb*Fn&iAoGQ zkNG%WBiF{ORa!Zy_>f0k`1l~OU4sXCmJjo;VJOH;d(z>5@meSn9H<&RYVggF5?BGi zYaxFf^{TU_+9bI=zD06c(9?Hr*0oK%Zz|}Xi7(ubKCk7AXW8%P^1<={UC(}h^+)#u z-@WAl_40wzZkoxM2npvuA4JMjYLy~VrsVFEk!2I}ftAW-v{L1%^}?!m+5jV{tC$V3 zKZfslhw;Idc8-mk^-FVL@T6epijwJMmKNO>DGV4;pQ!{tQIO8)U`zk8vn@U>a1fAf zlvj6kT)a#pf4e*9xAj%;+R()I2WLe|AGrVl!dNZZ$uN@tm9Dj6KIW#-BwwwkSVkuqb_D!HE{K9=6up3#X6&Czg~uJX57pmDz4%OwaS?c$JOffd%n7o_-6;Y|#8kWgPxUV7;BM z!^-=)QvHr4q9f%>rJk&fkw@jz+{)8_eS&J6Em?KI60fAgfhj!?3Xfk}fJs8Zv!w+c zP>$VsyK>0!+j(-n>v$$@$bAs85mhUav+Po#Bm&|ed{ zPul77YXtcD9=nSK$Gz8-O{~}RF9-HniDS!hy4&ckciZpC&F8s9Qtvm7fQ8*inLdCYAb5qGif9g!#MsFi_x(vy*SwzIDC@=#DjMiL9IEo zy^z4^5Bp;{J(x$+k&xsanZrXTe6(|Ng;$Qay@sI9!HZ5W*u3zd-vB6d0 z@GHwodDHtulFjc<2W;mRWej>w6=zgMT>^Vofdfyu;({ zHsF`_8EW0rMw( zezJL+Z+|Wuh1a(JlrnUVi54l>ixhtKfy4jq%6sl^xzI9<;5P?YBDsvKsQjq>?V0stQ zxewuL0W43!V^Ghh0R17~ngGv2dePwDxsd%A4pi?hXg4vef4NXTd}WFuz5|_1pVcJa z8I}4SK_Va&0oL2M?cS9Ewa#vY1QN54tS&LzbN70{J8$xybFN?9nBJJ6nS8$1X8)&R zOso#_UOlU#*xAto`e)lag|kEbq<&=#&NW;SZEfsqZ4C&ObJTmJNFhm>)TE3c;de23 zJH!I9cvh(?!bM3jJMjA@ObB5Ux_Dc`njtVDHmv7*W=p~@?w!h49w^d%y)K8aL&SD?yQifT_Us=g(t^4e4Nr6o=C zmU9tV{$#5BjVQhzE^m1eS-*6uz2{N+kEZG|f+}w-s{QLx<*h@NuOd}G>wE+pS#Y{P zuJPoD!~0R~Qy48_O_+!Yetn7B~^LPWBWK@By^{ zOkXM?dLJN0fk1HLzE4$;&(R-*htV_fr_JXaw5(e}7X``=TW?yv^wX!zR-$%jB+U9| z9-5Qm)Y^<3$Jq849`{K? za$dBc=8YLO&b1C?cpED+{A)9^ym;To^_wUm>*qwhlXMo5<+T)%^9rj?!}YTglJ&nr ztqTEE`PaIU(j}Wtj`JyGQUw4WchJ>)u|T_Q&OS zrN-A~8?yZGDF#qQ30a?VVzNF*DPxbFsqu`*OEOj7Mijps*Z;e*pQ}>8*$GcYfvk6i zoJeJ~LL;vx)x|2b>0{lroN|r4x3_R_h~bhmi-XLoedGI$PNHM|#)>(zH~_g>8# z&nz}HZKlMnfKk(VpKKPHK;Q{4Y*HjD{Ci0<4 zbkgP3CB4EWR`da(kY8a9u!{xv()}v;4=j`E#h8=%pT|VY<=hL6s8MZPj&QaWOqCcn zGqF7PQkG1Vw*4k{WZc$se0I(}2Pa@Q-b{CH<#xY3<5m&WaK!o%J5um(fcRpjEmf|z zJnlH%v)5}>i3l2Hi)WX2y)sNle?6#!FCU~$%6RdVuzz^={P?gNW7}ZP!6KnChP}MMJd(RUmX9@`B8<}Zuq@BR zwx&PFE~T05`GVr{eK;P5*r-fF>Cy2_4KFaUo`wCoQ4)E0PCXNQWiNhz-T~)_Rp1FJ zh6nbyqVR_)z3Xl)eL5U7Xbi2WVf(DGk|p>#CK}RBgRDv3B5g(0hcfVo>7Hw;T^F=; zS1+iJX}D>cy5gpe-!NWrj#tR1^G7RH1N@wIn=(uP9I*j5wmgLd`1wqkOHTr)W}WDW zGkbr|=JTrDl|4Q}eznf8$gz#M@du`LeY72)`9)b=lw;mh#&GGv4&BZ0wa4YC%5AFj zI`$XFzjQx#XR6(<0eI?09JV^P#m?;r=4o`lIOL#W-t4WcWEp$vCMR`HL-aRCc5KgTJ46B0o&@22)1cQrFU`(8Y&(@ zqco&+-UmX4DFa9%#s$`~zM1k>En zHjoH5EB{_>PeA;Qhj7PlBKN+1Q9dg-plR<~hO%e@G^6iubAk(t!^z<7vW!w+QT8%Q zHv-zD!UwSY)zF*d&fh@p8X-Ao#yonnpkBb`No<*H*KCfhTebP}N zBCdx|^BZ0_v*KoK095$*x0Yrw|CqHn?ls(S?(i(LWhq+_@vYF z8*87gzr3gk^oKY(b;z+Z^(>~H4`#md-_TEMDh>@6W^S*E$5FFMO&oWu@Kk~Hi(uv3iz|*fez=46^!kU##iU0> zSH{va4@LxAlsFeQ%Ng)0-Ri7;k#_u366Px1c^@_T(8mBQk!Ckyi1b86k}eeQ08Tp> z^tK(o8ovgAqiwY{Byo)`o~`$%96RacUiLm79j$zVt{zZK{f6;kHFi4f(A~hw^78t3 zxdqvta}0J%t|@G-5nl%r%Y&Ur?#W)t(n?hkx#{<(EU%oTxZ+zWF8fxBI}|{1TWV2U z{n8W>Z$WX7gcSFSzICWdar;VBrX)q+^fDj2 zu49=tW}T?;q0dpmuUDgi?%a^QFB~{Qny{)w6@)Jy-*=RJ`!4AAhD>nH0pR9OB%fwZ z!L|t3q4KO>Y}~XK4yX1(>tw)Z5#g?+p3-jMIhNpXnV}@4KirO$MX2XIDV9M+CGS$!C0P(miP8Df;jnuUoW5}Wba^N6v{wqi-S`%s=6X~8!}sC*aXVv3w-XLW zmygyngo%B;53~$G`|H?KR~Eb+Q+Iuf)8x822d*Du9@gQbocJb(xV>(QsFvpU)j3527Pd<-W4YL_!QrUhjdGy#sM92 zVTsqK=Nmt+C<+t(bRXN-FET#*UV#3*K4DC;a^qJv#&&sA$`AO%rAy_F@qkZ!nfX4< z{nNHOfPYiJpAqFy(z+2$ht4B18ue`*@&V&`AG(0Y_nRpdDCbnUOuj^Yuf)!T|E2^l`V4;#Cp{t+>uH6 zdQ0q$c@OO|8|Rn~oqWDk+oW&bOJ!>7;jPq``muI0BS4S0nLSLuY0NE#PKV9zCF|Xf zZAB;N4pnmXr7<6frflDB9rS%atM@Q#c9CCucU@SgOU!egm#3UGb);IQ)oBtUbX#In5rHSDL1>mN_`aI>XUT#;ZX=zIy#= zefVa<`;>3X=eGVb5%ZPf*{eAxxvNTui;#213>9$-&eC#+laiP{e>crb%AwV-8A5zo zri6kI6dlI&kgqB^+1|@51-Fbd)rz-9zvP-~#akr!Xffp7C4pw9lfkw5_lA<`w0NOST6F%M zp*G{+TA_ay%hA$n1<-oA#At;+W><;nsT1#YWv=Z=XAMXFa98SceE08bzN)P)PH|~P z#M%4W*N%XEd5Hy|szylOU7DNe!MH}_uG%Bjsz@34@+*FTRNh|}saxM(-iLcLL7VS> zkmF<&7BnP3rT4EeR(N}+8{=juLJ^}-af+lO6zB=!)&n2^`rX6L+=|Vj7Zi4S2>A*X z5VEdr#~4$Jf=G9tiZiF(oarwxoo=T zjoiS}n(if5$76xr?Z)IH^rm28laZ+g`2O}hGrdk<2h65fEI~(-KO}61y_OQ@zeD-Y zpu%DQIZ&n*kS@-Lmhn5A&ThaB5Z)WC4P*hI3O@!c=#9Y!KN8=3wHmO~P`Yz~Z&f7_ z(W&+?5F_vr9BZdh>?bYHtZG+@YzG0wmZCrUp{Eb^=s`%azoxOqfIEy0ODeSfV(5 z1;EP{HJ<-U@c&FH?ooP>xIZw5=a^^{E`U-@aD=*B3i77z%uKX7I2pp-1h~EwJ6tqn zEAIk@N5Eju0k{fg=TcZ5?!uZd03?hr@fe`J4wJ1Dj~?ZEl#JIEz*kV_tviF(fO7Rn z_ns!6qh0{hxhL3-%4kOVm#1FnG@~rGKBDmW0u6A;Y)h(Om6A zi5Ap=^X-m)?C>=-aQCZW`~RjT@k!dkmD5INTDV~l&O3$e|2V~X{3c^aeEL|PI}XbI zIq3Y~Ii7S+q%IjQQ~2kJKQ?Z5O(O9nz{@WMCJfrl0{S2g!7_7AOZOp!n>ml|@4tZU zK8DBr?gHcaeOYMSzdhVp1ph(0*IrDu|6=3mv;k9vdnl8L>7YTR|0d({Us3)|Hk0@t zfY0AXy3YsMg7zT$Z~^Y3>g;Fda|C1o)*kw?adGr8bvimi;u9%+*>QF{`zhIx#$amX z5FCdVT!MA+5<7n2Wyt@=&#l*2Ab#bWK&RZ~S)akZ>n#|^w@Cl4cX|31E*~e{gMF9M zb=m-AUeW+g2jP_JFMey zD^7;nUV_AvJrRDadjd%JEwxF!cpVb|8}O-h zNq0#-67O6e;T;-)3ZI7TdQ1z1@;4Ix)Ox;!C%yx`qtP}to_^0J#{DzEe-WkH=QbEG zTLheNw-)SllEM8h(jk85P6*!UM5Y(m*|hZiyP$C6x{%>>yBd$*Bb4;N(*xdhg^}((dXo4I zY2oVqo(kVN9O2Rk(tRi313^{9tw_`IX&c23Cjl!Qy#aoWCc`a@MffrW>E1c^V4}C4njLSR+IV_(;z|F^cy{W5nZYYt$?bN!v#4+k4BkHrv} zheM2q3mb}`w6eoR4nywWhLQezMj^b~Xat+2kp7ziw**rS(*dp?M}|8%o}I7NM7DeH zNo@D~;4Ys``ahUL;@zjR_`LI>A7S) z72xUfV7x3aZCraU7~xbiYZXJ28bpK*>TDr~uHXg42JL)aY zdw4GnLZp?!V#7v&55?pK-Umyz9$2-NY5HN$ffQXC8w3{v47NqEfgPDndwYc62N>dj zppPShD*!5;$avM9NqiQCd%KbNP`4k`Z~GYHg~A(}t3}Clp1T`Qw}l7W|F2@Cd)?yf zaN~H~wIxXRLlo}miSWsu?D+4%y`>l1U*b)=&jGx9$)D2`ysv=xwM(0pew8vXJ^;Fu zH7%Y)MHFrWK<7%P#Y^T1x3~)Fo=xF#z6hUG9l`eiKh!|5oF5r}y&pRtO>NTs6W}B2 zu)_z}CEZKaqvlUD5}!ul4lNMAv!(I&sSt$x=K*wTMTYM~;XbVqKC$(WjiXysyxDC? zykA@6>3#(N0qqd%6KQ^^o)7(vE6L*Zqj*y-+2k?!Ty z2v1U*R*r`8#=}YavHkaeyHHE|ixSx3UxK?jk@P<}0O6GeA~3?OQ@$!BCo%EkF z^w;QRr@uwRQM&y=^X;J#rscDFBs<)kQH3j~-Dqg16f)ki6m~l6K_5=`SlGA5k>O${ zlK6v(>~!``M(!7;7OuS8r=jo_rz7}k2GCP;OiO1t6|PGfMMtEu)A=$FxeuIgJRi#i zY=1fBzMm)D>xCxH^F_wPE&7A>udo>5>j8fG6Tyxv5j?Vz4DY|nv~pab!uhNQx)D^9 z4P3)cCvrVb{1~D-P?u2PuPuM={@ZD z{lWbukAKa5q`UooYTw__jyLlFa=&@Vv~r(4LiOVrcDxUid*)dZcg_S;0hdYlcna@x z725wAf^z{{Unj$5QF!(Za7X#&VN-V{Z&;+e5fypp94ItD(T*^I>NJR7S5{IL_ZY1jX#20 z{n_!|Y9aR_wT!1P2|)f`0@&e}Q0`W>5iYJ{JYAo<1^Cx9?mwbl;o3zMNQUnd$c~@E zhf5;Kz;YG(u|Im}vx_pLOH_lRY!dc_#cg7KAB<_B#aN}f5A;YV#li?pml5)Av>MSK;!9G zY=rzX0lsRA;NoWNcrMLJ_wCIkKeTQYYhgTIJotBOiQsvF7lIIM*ourlqBV(MZ_Q5s zSzF}3Hw3{J9m#N`x{~;~P=x={4Z#xK5&YVno&KvH$i01-}P2O*teJ*j^0h4ALR zsre;k=UZJyx|>Ci_iS!%nySSkk@yID{XW zj9}X-#>=w|{4Y%kj<k&M+feb%n6N%ezfqVeI*bd`l2ZEOY_S#9tlkYzHE From 2a39a976997f6670f41471bfc4b82f30f57fe1a5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 01:32:04 -0500 Subject: [PATCH 037/193] Test the direct index once in number-key lookups, and inline the probe in loops --- .../compiler/src/backend/llvm/emit-module.ts | 268 ++++++++++-------- .../compiler/src/backend/llvm/loop-state.ts | 21 ++ .../backend/llvm/map-number-lookup.test.ts | 57 +++- .../src/backend/llvm/map-number-lookup.ts | 10 +- .../map-number-lookups-in-loops.ts.json | 6 + tests/corpus/map-number-lookups-in-loops.ts | 158 +++++++++++ .../map-number-lookups-in-loops.ts.json | 1 + 7 files changed, 393 insertions(+), 128 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-number-lookups-in-loops.ts.json create mode 100644 tests/corpus/map-number-lookups-in-loops.ts create mode 100644 tests/harness/corpus-costs/map-number-lookups-in-loops.ts.json diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 6f261d4bb..092a87bf1 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -1377,25 +1377,139 @@ export function emitModuleParts(emitter: LlEmitter): string[] { return out; } -/** The out-of-line hashed probe of a number-key map (scr_map_find_f64): - * `keyLines` define the normalized key word %kb from the parameter, and - * `runtimeKey` is the double passed to the runtime lookup. A map with at - * most SCR_MAP_LINEAR_LIMIT entries keeps no bucket table: the absent-key - * filter is tested before its entries are scanned. A bucket table is - * probed without the filter: the filter keeps two of its 64 bits per key, - * so past a few dozen keys it rejects few absent keys, and the probe's - * first bucket read answers those. Compact tables tag each 24-bit entry - * index (+1) with the hash's top byte; wider tables take the runtime - * lookup. */ +/** The `.loop` lookup helpers only read memory and always return, as + * declared: LLVM then merges two lookups of one key (a has() followed by + * get()) before it inlines them. */ +const INLINED_LOOKUP_ATTRS = "alwaysinline memory(read) nounwind willreturn"; + +/** The number-key entry lookups (sc_map_entry_f64 and sc_map_entry_f64_int) + * and their hashed probes. With `loop` they are the `.loop` flavor, which + * lookups inside a loop call (emitNumberMapEntry): each of its functions is + * inlined, so a lookup that finds its key in a bucket table makes no call, + * and the loop keeps no argument moves or spills around one. */ +function numberMapEntryHelpers(sz: string, loop: boolean): string[] { + const flavor = loop ? ".loop" : ""; + const attrs = loop ? `${INLINED_LOOKUP_ATTRS} ${FN_ATTRS}` : FN_ATTRS; + let mapTmp = 0; + const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`); + return [ + `define internal ptr @sc_map_entry_f64${flavor}(ptr %m, double %k) ${attrs} {`, + `entry:`, + ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, + ...(sz === "i64" + ? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`] + : [` %nd = load i32, ptr %ndp`]), + // A non-integral key inside the table is as absent as one outside it. + ...mapIndex.lines.map((line) => ` ${line}`), + ` br i1 ${mapIndex.ok}, label %slot, label %slow`, + `slot:`, + sz === "i64" + ? ` %i = add i64 ${mapIndex.wide}, 0` + : ` %i = trunc i64 ${mapIndex.wide} to ${sz}`, + ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, + ` %dense = load ptr, ptr %dp`, + ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, + ` %s = load i32, ptr %sp`, + ` %empty = icmp eq i32 %s, 0`, + ` br i1 %empty, label %miss, label %hit`, + `hit:`, + ` %e32 = sub i32 %s, 1`, + sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, + ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %entries = load ptr, ptr %ep`, + ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`, + ` ret ptr %found`, + `miss:`, + ` ret ptr null`, + `slow:`, + ` %hashed = call ptr @sc_map_entry_f64_hashed${flavor}(ptr %m, double %k)`, + ` ret ptr %hashed`, + `}`, + ``, + // The same lookup for a key the emitter knows is an integer (`%ki`, + // the double `%k`'s exact value): the direct index tests the integer + // itself, so the double is converted only by the hashed probe. A + // negative key is not below the index's size as an unsigned value. + `define internal ptr @sc_map_entry_f64_int${flavor}(ptr %m, i64 %ki, double %k) ${attrs} {`, + `entry:`, + ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, + ` %nd32 = load i32, ptr %ndp`, + ` %nd = zext i32 %nd32 to i64`, + ` %inside = icmp ult i64 %ki, %nd`, + ` br i1 %inside, label %slot, label %slow`, + `slot:`, + sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`, + ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, + ` %dense = load ptr, ptr %dp`, + ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, + ` %s = load i32, ptr %sp`, + ` %empty = icmp eq i32 %s, 0`, + ` br i1 %empty, label %miss, label %hit`, + `hit:`, + ` %e32 = sub i32 %s, 1`, + sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, + ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %entries = load ptr, ptr %ep`, + ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`, + ` ret ptr %found`, + `miss:`, + ` ret ptr null`, + `slow:`, + ` %hashed = call ptr @sc_map_entry_f64_hashed_int${flavor}(ptr %m, i64 %ki)`, + ` ret ptr %hashed`, + `}`, + ``, + // Hashing (scr_map_find_f64): the SameValueZero key word and its + // scr_key_mix hash. sc_map_entry_f64_int's probe converts the integer + // view to the key word, which is never NaN or -0, and so skips the + // normalization. + ...hashedNumberProbe( + `sc_map_entry_f64_hashed${flavor}`, + loop, + "double %k", + sz, + [ + ` %nan = fcmp uno double %k, %k`, + ` %zero = fcmp oeq double %k, 0.0`, + ` %kz = select i1 %zero, double 0.0, double %k`, + ` %kzb = bitcast double %kz to i64`, + ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`, + ], + "%k", + ), + ...hashedNumberProbe( + `sc_map_entry_f64_hashed_int${flavor}`, + loop, + "i64 %ki", + sz, + [` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`], + "%kf", + ), + ]; +} + +/** The hashed probe of a number-key map (scr_map_find_f64), out of line, + * or inlined with `loop`: `keyLines` define the normalized key word %kb + * from the parameter, and `runtimeKey` is the double passed to the runtime + * lookup. A map with at most SCR_MAP_LINEAR_LIMIT entries keeps no bucket + * table, and neither does a map with the direct index, whose lookups reach + * the probe only for a key outside the index, which is absent. In a small + * map the absent-key filter is tested before its entries are scanned. + * A bucket table is probed without the filter: the filter keeps two of its + * 64 bits per key, so past a few dozen keys it rejects few absent keys, and + * the probe's first bucket read answers those. Compact tables tag each + * 24-bit entry index (+1) with the hash's top byte; wider tables take the + * runtime lookup. */ function hashedNumberProbe( name: string, + loop: boolean, param: string, sz: string, keyLines: string[], runtimeKey: string, ): string[] { return [ - `define internal ptr @${name}(ptr %m, ${param}) noinline ${FN_ATTRS} {`, + `define internal ptr @${name}(ptr %m, ${param}) ${loop ? INLINED_LOOKUP_ATTRS : "noinline"} ${FN_ATTRS} {`, `entry:`, ...keyLines, ` %x1s = lshr i64 %kb, 30`, @@ -1415,7 +1529,14 @@ function hashedNumberProbe( ` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, ` %ents = load ptr, ptr %eps`, ` %linear = icmp eq ${sz} %nb, 0`, - ` br i1 %linear, label %filter, label %buckets`, + ` br i1 %linear, label %unbucketed, label %buckets`, + // A map without a bucket table either indexes its keys directly, and + // then holds none outside the index, or scans its entries. + `unbucketed:`, + ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, + ` %nd = load i32, ptr %ndp`, + ` %indexed = icmp ne i32 %nd, 0`, + ` br i1 %indexed, label %miss, label %filter`, `filter:`, ` %f1s = lshr i64 %h, 44`, ` %f1a = and i64 %f1s, 63`, @@ -1582,117 +1703,22 @@ export function helperDefs(emitter: LlEmitter): string[] { } // Number-key Map/Set lookups probe the runtime's direct index inline: // while ndense is nonzero every live key is an integer below it, so an - // in-range integral key reads its slot (entry index + 1, 0 = absent) and - // any other key is absent. Maps without the index (ndense 0) hash the - // key in sc_map_entry_f64_hashed, which scans the entries of a small map - // or probes a compact bucket table as scr_map_find_f64 does, and calls - // the runtime lookup for a wide table. It stays out of line so that the - // direct-index path stays small at every call site. Both only read - // memory, so LLVM merges lookups of one key when nothing is stored in - // between (a has() followed by get(), for example). - if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL)) { - const sz = emitter.sizeType; - let mapTmp = 0; - const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`); + // in-range integral key reads its slot (entry index + 1, 0 = absent). + // Any other key goes to sc_map_entry_f64_hashed, which probes a compact + // bucket table as scr_map_find_f64 does, calls the runtime lookup for a + // wide table, and in a map without a bucket table answers absent when the + // map has the index (a map with buckets keeps no index, scr_map.h) and + // scans the entries otherwise. A lookup in a hashed map so tests the + // index once. The probe stays out of line so that the direct-index path + // stays small at every call site, except in the `.loop` flavor + // (numberMapEntryHelpers). Both only read memory, so LLVM merges lookups + // of one key when nothing is stored in between (a has() followed by + // get(), for example). + if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL)) defs.push( - `define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`, - `entry:`, - ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, - ...(sz === "i64" - ? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`] - : [` %nd = load i32, ptr %ndp`]), - // A non-integral key inside the table is as absent as one outside it. - ...mapIndex.lines.map((line) => ` ${line}`), - ` br i1 ${mapIndex.ok}, label %slot, label %outside`, - `slot:`, - sz === "i64" - ? ` %i = add i64 ${mapIndex.wide}, 0` - : ` %i = trunc i64 ${mapIndex.wide} to ${sz}`, - ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, - ` %dense = load ptr, ptr %dp`, - ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, - ` %s = load i32, ptr %sp`, - ` %empty = icmp eq i32 %s, 0`, - ` br i1 %empty, label %miss, label %hit`, - `hit:`, - ` %e32 = sub i32 %s, 1`, - sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, - ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, - ` %entries = load ptr, ptr %ep`, - ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`, - ` ret ptr %found`, - `miss:`, - ` ret ptr null`, - `outside:`, - ` %dense.any = icmp ne ${sz} %nd, 0`, - ` br i1 %dense.any, label %miss, label %slow`, - `slow:`, - ` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`, - ` ret ptr %hashed`, - `}`, - ``, - // The same lookup for a key the emitter knows is an integer (`%ki`, - // the double `%k`'s exact value): the direct index tests the integer - // itself, so the double is converted only by the hashed probe. A - // negative key is not below the index's size as an unsigned value. - `define internal ptr @sc_map_entry_f64_int(ptr %m, i64 %ki, double %k) ${FN_ATTRS} {`, - `entry:`, - ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, - ` %nd32 = load i32, ptr %ndp`, - ` %nd = zext i32 %nd32 to i64`, - ` %inside = icmp ult i64 %ki, %nd`, - ` br i1 %inside, label %slot, label %outside`, - `slot:`, - sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`, - ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, - ` %dense = load ptr, ptr %dp`, - ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, - ` %s = load i32, ptr %sp`, - ` %empty = icmp eq i32 %s, 0`, - ` br i1 %empty, label %miss, label %hit`, - `hit:`, - ` %e32 = sub i32 %s, 1`, - sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, - ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, - ` %entries = load ptr, ptr %ep`, - ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`, - ` ret ptr %found`, - `miss:`, - ` ret ptr null`, - `outside:`, - ` %dense.any = icmp ne i64 %nd, 0`, - ` br i1 %dense.any, label %miss, label %slow`, - `slow:`, - ` %hashed = call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)`, - ` ret ptr %hashed`, - `}`, - ``, - // Hashing (scr_map_find_f64): the SameValueZero key word and its - // scr_key_mix hash. sc_map_entry_f64_int's probe converts the integer - // view to the key word, which is never NaN or -0, and so skips the - // normalization. - ...hashedNumberProbe( - "sc_map_entry_f64_hashed", - "double %k", - sz, - [ - ` %nan = fcmp uno double %k, %k`, - ` %zero = fcmp oeq double %k, 0.0`, - ` %kz = select i1 %zero, double 0.0, double %k`, - ` %kzb = bitcast double %kz to i64`, - ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`, - ], - "%k", - ), - ...hashedNumberProbe( - "sc_map_entry_f64_hashed_int", - "i64 %ki", - sz, - [` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`], - "%kf", - ), + ...numberMapEntryHelpers(emitter.sizeType, false), + ...numberMapEntryHelpers(emitter.sizeType, true), ); - } // Map/Set iteration steps read the entries array inline: an index is // live when it is below the entry count and its entry's cached hash is // nonzero (a tombstone's is zero), as scr_map_iter_live decides, or for diff --git a/packages/compiler/src/backend/llvm/loop-state.ts b/packages/compiler/src/backend/llvm/loop-state.ts index ce9189a45..be33ae517 100644 --- a/packages/compiler/src/backend/llvm/loop-state.ts +++ b/packages/compiler/src/backend/llvm/loop-state.ts @@ -3,11 +3,19 @@ * local arrays they rely on. */ import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js"; import type { IrFunction, IrStmt } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; import { findInitializedByteLoopBindings } from "./byte-windows.js"; import type { DenseArrayLoop } from "./dense-array-loops.js"; import type { LlEmitter } from "./emitter.js"; import { defineModuleState } from "./emitter-state.js"; +/** The most IR nodes a function may have for its in-loop number-key + * lookups to inline the hashed probe (emitNumberMapEntry). Each inlined + * probe adds code to its function, and LLVM's passes over a large function + * take longer for it; a small function's loop of lookups, such as a k-mer + * count, gains the most from it. */ +const MAX_INLINED_LOOKUP_NODES = 200; + /** Sets the loop state for `fn` at the start of its emission; `numericFn` * is `fn` with its initializer bindings (emitFunction). */ export function beginFunctionLoops( @@ -32,6 +40,15 @@ export function beginFunctionLoops( loops.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes") ? findInitializedByteLoopBindings(fn) : new Map(); + loops.inlinedLookups = fewNodes(fn.body, MAX_INLINED_LOOKUP_NODES); +} + +/** Whether `body` has at most `max` statements and expressions. */ +function fewNodes(body: IrStmt[], max: number): boolean { + let count = 0; + const visit = (): boolean => ++count <= max; + everyStmtList(body, { expr: visit, stmt: visit }); + return count <= max; } /** The state LOOPS holds on each emitter. */ @@ -58,6 +75,9 @@ export interface LoopState { /** Counted loops whose integer-form safety test an enclosing version * guard already passed. */ readonly provenCountedGuards: Set; + /** Number-key lookups in this function's loops inline the hashed probe: + * the function has at most MAX_INLINED_LOOKUP_NODES nodes. */ + inlinedLookups: boolean; } export const LOOPS = defineModuleState(() => ({ @@ -73,4 +93,5 @@ export const LOOPS = defineModuleState(() => ({ bytesLoopEntries: null, bytesLoopFunction: null, provenCountedGuards: new Set(), + inlinedLookups: false, })); diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts index f7bd87af1..5e1e006d9 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts @@ -117,8 +117,9 @@ test("string keys and reference values are retained inline, boolean slots compar expect(text).toContain("call ptr @scr_map_iter_val_ref("); }); -/** work(map, k): map.has(k) on a number-keyed map. */ -function lookupFixture(key: IrExpr = ref("k", F64)): IrModule { +/** work(map, k): map.has(k) on a number-keyed map, or with `loop`, the + * condition of `while (map.has(k)) {}`, after `filler` statements `k;`. */ +function lookupFixture(key: IrExpr = ref("k", F64), loop = false, filler = 0): IrModule { const map: IrType = { kind: "map", key: F64, value: F64 }; const params = [ { localId: "map", name: "map", type: map }, @@ -138,7 +139,17 @@ function lookupFixture(key: IrExpr = ref("k", F64)): IrModule { returnType: BOOL, loc, locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: true })), - body: [{ kind: "return", value: has, loc }], + body: loop + ? [ + ...Array.from({ length: filler }, (): IrStmt => ({ + kind: "exprStmt", + expr: ref("k", F64), + loc, + })), + { kind: "while", cond: has, body: [], loc }, + { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }, + ] + : [{ kind: "return", value: has, loc }], }; return { irVersion: 17, @@ -159,6 +170,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide module, )![0]; expect(helper).toContain("call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)"); + // A key outside the direct index goes to the probe, whose small-map + // branch tells a map with the index (field 16) from one without it. + expect(helper).toMatch(/br i1 %ix\d+, label %slot, label %slow\n/); + expect(helper).not.toContain("dense.any"); const hashed = /^define internal ptr @sc_map_entry_f64_hashed\(ptr %m, double %k\) noinline [^]*?^}/m.exec( module, @@ -171,7 +186,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide // Filter (field 10) before the small-map scan of the entry count (field // 8) only, and compact buckets (fields 13 and 14) tagged with the hash's // top byte. - expect(hashed).toContain("br i1 %linear, label %filter, label %buckets"); + expect(hashed).toContain("br i1 %linear, label %unbucketed, label %buckets"); + expect(hashed).toMatch( + /^unbucketed:\n[^]*i32 0, i32 16\n[^]*br i1 %indexed, label %miss, label %filter\n/m, + ); expect(hashed).toContain("getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10"); expect(hashed).toContain(`load i32, ptr %nep`); expect(hashed).toContain(`load i32, ptr %nbp`); @@ -198,6 +216,7 @@ test("a key with an integer view tests the direct index on the integer", () => { expect(helper).toContain("icmp ult i64 %ki, %nd"); expect(helper).toContain(`getelementptr inbounds i32, ptr %dense, ${sz} %i`); expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)"); + expect(helper).toContain("br i1 %inside, label %slot, label %slow"); expect(helper).not.toContain("fptosi"); // Its key word is the integer's double, which needs no NaN or -0 // normalization; the runtime lookup gets that double. @@ -220,6 +239,36 @@ test("a key with an integer view tests the direct index on the integer", () => { } }); +test("a lookup inside a loop calls the flavor that inlines the hashed probe", () => { + for (const key of [ref("k", F64), { kind: "numLit", value: 12, type: F64, loc } as IrExpr]) { + const { work, module } = emitted(lookupFixture(key, true), 64); + const name = key.kind === "numLit" ? "sc_map_entry_f64_int" : "sc_map_entry_f64"; + expect(work).toContain(`call ptr @${name}.loop(`); + expect(work).not.toContain(`call ptr @${name}(`); + // They only read memory and always return, so a has() and get() of one + // key merge before they are inlined. + const helper = new RegExp( + `^define internal ptr @${name}\\.loop\\([^)]*\\) alwaysinline memory\\(read\\) nounwind willreturn [^]*?^}`, + "m", + ).exec(module)![0]; + const hashed = + key.kind === "numLit" ? "sc_map_entry_f64_hashed_int" : "sc_map_entry_f64_hashed"; + expect(helper).toContain(`call ptr @${hashed}.loop(`); + expect(module).toMatch( + new RegExp( + `^define internal ptr @${hashed}\\.loop\\([^)]*\\) alwaysinline memory\\(read\\) nounwind willreturn `, + "m", + ), + ); + // A lookup outside a loop keeps the out-of-line probe, and so does a + // lookup in a loop of a function of more than 200 nodes. + expect(emitted(lookupFixture(key), 64).work).toContain(`call ptr @${name}(`); + const large = emitted(lookupFixture(key, true, 100), 64).work; + expect(large).toContain(`call ptr @${name}(`); + expect(large).not.toContain(".loop("); + } +}); + test("a key view converted in a branch is not reused by a later lookup of the same constant", () => { // work(map, k, taken): if (taken) return map.has((k = 2 ** 40)); return map.has(2 ** 40). const wide = 2 ** 40; diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts index 17a1563c3..da2e5981a 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts @@ -32,6 +32,7 @@ import type { IrExpr, IrType } from "../../ir/ir.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { exactInteger, widenInteger } from "./integer-values.js"; +import { LOOPS } from "./loop-state.js"; import { mapKeyAccess } from "./shapes.js"; export const NUMBER_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_f64(ptr, double)"; @@ -55,19 +56,22 @@ export function noteNumberMapKey(host: LlvmEmitterContext, key: LlValue, expr: I /** The entry for `key`, or null when absent. Valid until the next mutation. * A key with an integer view (noteNumberMapKey) tests the direct index on - * that integer instead of converting the double back. */ + * that integer instead of converting the double back. A lookup inside a + * loop of a small function (LOOPS.inlinedLookups) calls the helpers' `.loop` + * flavor, which inlines the hashed probe. */ export function emitNumberMapEntry(host: LlvmEmitterContext, map: string, key: string): string { host.declare(NUMBER_MAP_ENTRY_DECL); const entry = host.B.tmp(); + const flavor = host.loopDepth > 0 && LOOPS.of(host).inlinedLookups ? ".loop" : ""; const integer = host.numberMapKeyIntegers.get(key); if (integer) { const wide = widenInteger(host, integer); host.B.line( - `${entry} = call ptr @sc_map_entry_f64_int(ptr ${map}, i64 ${wide}, double ${key})`, + `${entry} = call ptr @sc_map_entry_f64_int${flavor}(ptr ${map}, i64 ${wide}, double ${key})`, ); return entry; } - host.B.line(`${entry} = call ptr @sc_map_entry_f64(ptr ${map}, double ${key})`); + host.B.line(`${entry} = call ptr @sc_map_entry_f64${flavor}(ptr ${map}, double ${key})`); return entry; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-number-lookups-in-loops.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-number-lookups-in-loops.ts.json new file mode 100644 index 000000000..5c06e9db7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-number-lookups-in-loops.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/map-number-lookups-in-loops.ts" + ], + "diags": [] +} diff --git a/tests/corpus/map-number-lookups-in-loops.ts b/tests/corpus/map-number-lookups-in-loops.ts new file mode 100644 index 000000000..afc45a539 --- /dev/null +++ b/tests/corpus/map-number-lookups-in-loops.ts @@ -0,0 +1,158 @@ +// Number-key Map and Set lookups inside loops while the collection changes +// between its direct index, a linear scan and a bucket table. + +// Dense keys, then a sparse key mid-loop moves the map to hashing; later +// lookups of the same keys still find them. +function denseToHashed(): string { + const m = new Map(); + for (let i = 0; i < 64; i++) m.set(i, i * 3); + let found = 0; + let sum = 0; + for (let i = 0; i < 200; i++) { + if (i === 50) m.set(1_000_000_007, -1); + const v = m.get(i % 80); + if (v !== undefined) { + found++; + sum += v; + } + if (m.has(1_000_000_007)) sum += 1; + } + return `${found} ${sum} ${m.size}`; +} + +// Counting into a map with many sparse keys: a bucket table from the start. +function hashedCounts(): string { + const counts = new Map(); + let key = 7; + for (let i = 0; i < 4000; i++) { + key = (key * 1103515245 + 12345) % 2147483648; + const k = key % 1009; + const c = counts.get(k * 1_000_003); + counts.set(k * 1_000_003, c === undefined ? 1 : c + 1); + } + let max = 0; + let total = 0; + for (const [k, c] of counts) { + total += c; + if (c > max) max = c; + if (k < 0) total = -1; + } + return `${counts.size} ${total} ${max}`; +} + +// Integer keys in a counter object map, as a k-mer count keeps them. +class Counter { + n: number; + constructor(n: number) { + this.n = n; + } +} + +function counterObjects(): string { + const freq = new Map(); + let key = 0; + let seed = 11; + for (let i = 0; i < 6000; i++) { + seed = (seed * 1103515245 + 12345) % 2147483648; + key = (key * 4 + (Math.floor(seed / 65536) % 4)) % 262144; + const cur = freq.get(key); + if (cur === undefined) freq.set(key, new Counter(1)); + else cur.n++; + } + let best = 0; + let bestKey = -1; + for (const [k, c] of freq) + if (c.n > best) { + best = c.n; + bestKey = k; + } + return `${freq.size} ${bestKey} ${best}`; +} + +// Fractional, negative, -0 and NaN keys looked up in a loop. +function doubleKeys(): string { + const m = new Map(); + const keys = [0.5, -3, -0, NaN, 2 ** 40, 1e-9, Infinity, -Infinity, 7]; + for (let i = 0; i < keys.length; i++) m.set(keys[i]!, `k${i}`); + const out: string[] = []; + const probes = [0.5, -3, 0, NaN, 2 ** 40, 1e-9, Infinity, -Infinity, 7, 8, 0.25, -1]; + for (let i = 0; i < probes.length; i++) out.push(`${m.get(probes[i]!) ?? "-"}`); + for (let i = 0; i < probes.length; i++) if (m.has(probes[i]!)) out.push("h"); + return out.join(","); +} + +// Deletes inside the loop, then the map refills with small keys. +function deleteAndRefill(): string { + const m = new Map(); + for (let i = 0; i < 1000; i++) m.set(i * 7919, i); + let hits = 0; + for (let i = 0; i < 1000; i++) { + if (m.has(i * 7919)) hits++; + m.delete(i * 7919); + } + for (let i = 0; i < 100; i++) m.set(i, i); + let sum = 0; + for (let i = 0; i < 150; i++) sum += m.get(i) ?? 0; + return `${hits} ${m.size} ${sum}`; +} + +// A Set's membership tests while it grows from a scan to a bucket table. +function setGrowth(): string { + const s = new Set(); + let present = 0; + for (let i = 0; i < 3000; i++) { + if (s.has((i * 37) % 1009)) present++; + s.add(((i * 37) % 1009) * 1000); + } + let again = 0; + for (let i = 0; i < 1009; i++) if (s.has(i * 1000)) again++; + return `${present} ${again} ${s.size}`; +} + +// Nested loops over two maps, the inner one keyed by the outer's values. +function nested(): string { + const outer = new Map(); + const inner = new Map(); + for (let i = 0; i < 300; i++) { + outer.set(i * 101, i); + inner.set(i, i * i); + } + let total = 0; + for (let i = 0; i < 300; i++) { + const v = outer.get(i * 101); + if (v === undefined) continue; + for (let j = 0; j < 3; j++) total += inner.get((v + j) % 300) ?? 0; + } + return `${total}`; +} + +// has() then get() of one key, with and without a write between them. +function hasThenGet(): string { + const m = new Map(); + for (let i = 0; i < 400; i++) m.set(i * 1_000_003, i); + let total = 0; + let misses = 0; + for (let i = 0; i < 800; i++) { + const k = (i % 500) * 1_000_003; + if (m.has(k)) total += m.get(k)!; + else misses++; + if (i % 3 === 0 && m.has(k)) { + m.set(k, m.get(k)! + 1000); + total += m.get(k)!; + } + if (i % 7 === 0 && !m.has(k + 1)) { + m.set(k + 1, i); + total += m.get(k + 1)! + (m.has(k + 1) ? 1 : 0); + } + } + return `${total} ${misses} ${m.size}`; +} + +console.log(denseToHashed()); +console.log(hashedCounts()); +console.log(counterObjects()); +console.log(doubleKeys()); +console.log(deleteAndRefill()); +console.log(setGrowth()); +console.log(nested()); +console.log(hasThenGet()); diff --git a/tests/harness/corpus-costs/map-number-lookups-in-loops.ts.json b/tests/harness/corpus-costs/map-number-lookups-in-loops.ts.json new file mode 100644 index 000000000..3ba66c4f0 --- /dev/null +++ b/tests/harness/corpus-costs/map-number-lookups-in-loops.ts.json @@ -0,0 +1 @@ +{ "plain": 8200, "san": 11500 } From 089a8e17996876f2f8194cb68d403482aaff054e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 01:47:31 -0500 Subject: [PATCH 038/193] fs.watch/fs.watchFile: fix listener removal, adapter ownership and argument checks - watcher.off/removeListener of a listener that is not the last one moves the later listeners down instead of up, so the removed closure leaves the list. - The watchFile adapters for zero- and one-parameter listeners release the Stats their listener has no parameter for, and the fs.watch adapter releases the event string once a dynamic value holds it. - A one-parameter fs.watch listener whose eventType is dynamic receives a boxed string through the emitted adapter. - fs.watch, fs.watchFile and fs.unwatchFile throw ERR_INVALID_ARG_VALUE for a path holding a null byte, and a new watchFile poller throws ERR_OUT_OF_RANGE when interval is not a uint32, as Node does. Both watchFile libCalls check for the pending exception. - fs.watchFile and fs.unwatchFile have surface-manifest entries, so a fence can deny them. - The self-hosted feature records list watchFile, and the node-types fence fixture uses fs.openAsBlob in place of the now-lowered fs.watchFile. Tests: tests/corpus/fs-watch-listener-shapes.ts (listener shapes, off of a middle listener, null-byte path) with its order baseline and cost file, the validation cases in fs-watch-file-polling.ts, and tests/harness/fs-watch-leaks.test.ts, which runs both programs under LeakSanitizer in the sanitized lane. --- .../src/backend/llvm/expr-stream-callbacks.ts | 108 ++++++++++-------- .../src/backend/llvm/lib-filesystem.ts | 47 ++++---- .../src/frontend/lowering/surfaces.ts | 16 +++ packages/compiler/surface-manifest.json | 14 +++ .../corpus/fs-watch-listener-shapes.ts.json | 6 + packages/runtime/src/scr_watch.c | 8 +- packages/runtime/src/scr_watch_file.c | 45 ++++++-- tests/corpus/fs-watch-file-polling.ts | 26 ++++- tests/corpus/fs-watch-listener-shapes.ts | 91 +++++++++++++++ tests/fixtures/node-types/fenced.ts | 4 +- .../__snapshots__/node-types-fenced.txt | 8 +- .../fs-watch-listener-shapes.ts.json | 1 + tests/harness/fs-watch-leaks.test.ts | 64 +++++++++++ .../self-hosting-native-toolchain.test.ts | 1 + .../harness/self-hosting-runtime-pack.test.ts | 1 + 15 files changed, 353 insertions(+), 87 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json create mode 100644 tests/corpus/fs-watch-listener-shapes.ts create mode 100644 tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json create mode 100644 tests/harness/fs-watch-leaks.test.ts diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 00ee4d636..1f92182d8 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -966,10 +966,12 @@ registerReplay("streamDoneFnFor", (host, args) => registerReplay("fsRenameThunkFor", (host, args) => fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }), ); -/** The (eventType, filename) fs.watch listener adapter: the runtime hands +/** The fs.watch listener adapter for an (eventType, filename) listener, or + * for an (eventType) listener whose parameter is dynamic: the runtime hands * the borrowed C strings, the thunk builds what the listener declared. The * listener owns its +1 arguments (the universal convention - * scr_watch_thunk_event follows). */ + * scr_watch_thunk_event follows), so a string the thunk boxes into a dynamic + * value is released once the box holds it. */ export function watchNameThunkFor( host: LlvmEmitterContext, cbT: IrType & { kind: "func" }, @@ -989,76 +991,84 @@ export function watchNameThunkFor( `entry:`, ` %evlen = call ${S} @strlen(ptr %event)`, ` %evstr = call ptr @scr_str_new(ptr %event, ${S} %evlen)`, - ` %slot = alloca ptr`, - ` %has = icmp ne ptr %name, null`, - ` br i1 %has, label %yes, label %no`, - `yes:`, ]; - const nameParam = cbT.params[1]!; - if (nameParam.kind === "dyn") { - host.declare(`declare ptr @scr_dyn_new_null()`); - host.declare(`declare ptr @scr_dyn_new_str(ptr)`); + const named = cbT.params.length === 2; + if (named) { d.push( - ` %nlen = call ${S} @strlen(ptr %name)`, - ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, - ` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`, - ` call void @scr_str_release(ptr %nstr)`, - ` store ptr %ndyn, ptr %slot`, - ` br label %go`, - `no:`, - ` %dn = call ptr @scr_dyn_new_null()`, - ` store ptr %dn, ptr %slot`, + ` %slot = alloca ptr`, + ` %has = icmp ne ptr %name, null`, + ` br i1 %has, label %yes, label %no`, + `yes:`, ); - } else { - if (nameParam.kind !== "union") - throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union"); - const def = host.unionsById.get(nameParam.unionId); - const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; - const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; - if (strTag < 0 || nullTag < 0) - throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms"); - if (host.nullableUnions.has(nameParam.unionId)) { - // `string | null` erases: the owned payload IS the union's value. + const nameParam = cbT.params[1]!; + if (nameParam.kind === "dyn") { + host.declare(`declare ptr @scr_dyn_new_null()`); + host.declare(`declare ptr @scr_dyn_new_str(ptr)`); d.push( ` %nlen = call ${S} @strlen(ptr %name)`, ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, - ` store ptr %nstr, ptr %slot`, + ` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`, + ` call void @scr_str_release(ptr %nstr)`, + ` store ptr %ndyn, ptr %slot`, ` br label %go`, `no:`, - ` store ptr null, ptr %slot`, + ` %dn = call ptr @scr_dyn_new_null()`, + ` store ptr %dn, ptr %slot`, ); } else { - const rc = vAdapters(host.shapeHost, STRING); - host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); - d.push( - ` %nlen = call ${S} @strlen(ptr %name)`, - ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, - ` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`, - ` store ptr %nu, ptr %slot`, - ` br label %go`, - `no:`, - ` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`, - ); + if (nameParam.kind !== "union") + throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union"); + const def = host.unionsById.get(nameParam.unionId); + const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; + const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; + if (strTag < 0 || nullTag < 0) + throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms"); + if (host.nullableUnions.has(nameParam.unionId)) { + // `string | null` erases: the owned payload IS the union's value. + d.push( + ` %nlen = call ${S} @strlen(ptr %name)`, + ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, + ` store ptr %nstr, ptr %slot`, + ` br label %go`, + `no:`, + ` store ptr null, ptr %slot`, + ); + } else { + const rc = vAdapters(host.shapeHost, STRING); + host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); + d.push( + ` %nlen = call ${S} @strlen(ptr %name)`, + ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`, + ` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`, + ` store ptr %nu, ptr %slot`, + ` br label %go`, + `no:`, + ` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`, + ); + } } + d.push(` br label %go`, `go:`); } let ev: string; if (cbT.params[0]?.kind === "dyn") { + // The box retains the string; the thunk's own reference goes. host.declare(`declare ptr @scr_dyn_new_str(ptr)`); - d.push(` br label %go`, `go:`, ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`); + d.push( + ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`, + ` call void @scr_str_release(ptr %evstr)`, + ); ev = "%evd"; } else { - d.push(` br label %go`, `go:`); ev = "%evstr"; } d.push( - ` %arg = load ptr, ptr %slot`, ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, ` %fn = load ptr, ptr %fnp`, - ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`, - ` ret void`, - `}`, - ``, ); + if (named) + d.push(` %arg = load ptr, ptr %slot`, ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`); + else d.push(` call void %fn(ptr %cb, ptr ${ev})`); + d.push(` ret void`, `}`, ``); host.resolveThunkDefs.push(...d); host.journal?.built(); return sym; diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index dc9c5c876..82685456e 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -1,7 +1,7 @@ /* Focused LLVM library-call emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag } from "../../ir/analysis.js"; -import { DYN, F64, STATS_T, STRING, type IrExpr } from "../../ir/ir.js"; +import { DYN, F64, STATS_T, STRING, type IrExpr, type IrType } from "../../ir/ir.js"; import { mangleRecordNew } from "../mangle.js"; import { arrNewCall, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; @@ -173,6 +173,22 @@ function emitObjEntryPairs(host: LlvmEmitterContext, e: LibCallExpr): LlValue { return out; } +/** The adapter of an fs.watch 'change' listener, by its declared shape: the + * runtime's for no parameters and for a string eventType, and one this + * module emits for a dynamic eventType or an (eventType, filename) listener. + * Only the runtime's adapters need a declaration. */ +function watchChangeAdapter(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string { + const runtime = + cbT.params.length === 0 + ? "scr_watch_thunk0" + : cbT.params.length === 1 && cbT.params[0]!.kind !== "dyn" + ? "scr_watch_thunk_event" + : null; + if (runtime === null) return host.watchNameThunkFor(cbT); + host.declare(`declare void @${runtime}(ptr, ptr, ptr)`); + return runtime; +} + export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; const alwaysThrowSym = FS_ALWAYS_THROW_SYMS[e.fn]; @@ -468,15 +484,7 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): const path = host.emitExpr(e.args[0]!); const cb = host.emitExpr(e.args[1]!); host.toHeap(cb); - const adapter = - cbT.params.length === 0 - ? "scr_watch_thunk0" - : cbT.params.length === 1 - ? "scr_watch_thunk_event" - : host.watchNameThunkFor(cbT); - // The named form is a definition this module emits; only the runtime's - // fixed adapters need a declaration. - if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`); + const adapter = watchChangeAdapter(host, cbT); host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`); const t = B.tmp(); B.line( @@ -509,16 +517,8 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): let adapter = "null"; let wantsName = false; if (code === 0) { - adapter = - cbT.params.length === 0 - ? "scr_watch_thunk0" - : cbT.params.length === 1 - ? "scr_watch_thunk_event" - : host.watchNameThunkFor(cbT); + adapter = watchChangeAdapter(host, cbT); wantsName = cbT.params.length === 2; - // The named form is a definition this module emits; only the runtime's - // fixed adapters need a declaration. - if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`); } else if (code === 1) { adapter = cbT.params.length === 0 ? "scr_watch_err_thunk0" : host.watchErrorThunkFor(cbT); if (cbT.params.length === 0) host.declare(`declare void @scr_watch_err_thunk0(ptr, ptr)`); @@ -553,7 +553,9 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): // (path, interval, persistent, listener) — the listener MOVES into // the poller's registry and the adapter is runtime-provided per // listener shape (zero, one or two Stats parameters). The poller - // re-arms a timer every interval, so this is a timer program. + // re-arms a timer every interval, so this is a timer program. May + // throw (a null byte in the path, or an interval that is not a + // uint32) — the standard pending check. host.usesTimers = true; const cbT = e.args[3]!.type; if (cbT.kind !== "func") @@ -569,18 +571,21 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): B.line( `call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`, ); + host.emitPendingCheck(); return { name: "", type: e.type }; } if (e.fn === "fs.unwatchFile") { // Removal is by closure identity: the listener stays borrowed // (never heap-moved), exactly like watcher.off's; an absent one is - // the NULL that removes every listener on the path. + // the NULL that removes every listener on the path. A null byte in + // the path throws. const path = host.emitExpr(e.args[0]!); const cb = e.args[1] === undefined ? null : host.emitExpr(e.args[1]!); host.declare(`declare void @scr_fs_unwatch_file(ptr, ptr)`); B.line( `call void @scr_fs_unwatch_file(ptr ${path.name}, ptr ${cb === null ? "null" : cb.name})`, ); + host.emitPendingCheck(); return { name: "", type: e.type }; } if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") { diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6a06fbaad..0ef9029fd 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2416,6 +2416,22 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] { fns: ["fs.callbackValue", "fs.callbackCall", "fs.callbackGlob"], note: "native filesystem callbacks, including stored platform adapter functions", }, + // fs.watchFile/fs.unwatchFile lower in lowerFsWatchFileCall, ahead of + // the module table. + { + id: "node-builtin.fs.watchFile", + kind: "node-builtin", + name: "fs.watchFile", + fns: ["fs.watchFile"], + note: "stat polling of a resolved path with Stats listeners, in statement position; bigint: true is refused", + }, + { + id: "node-builtin.fs.unwatchFile", + kind: "node-builtin", + name: "fs.unwatchFile", + fns: ["fs.unwatchFile"], + note: "removes one listener, or every listener, from a resolved path's poller", + }, // ── the Date slice (lowerDateCall/lowerNew): statics, construction, // stored read-only values, calendar getters, and ISO formatting. { diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..922c45e9e 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1279,6 +1279,13 @@ "name": "fs.unlinkSync", "status": "static" }, + { + "id": "node-builtin.fs.unwatchFile", + "kind": "node-builtin", + "name": "fs.unwatchFile", + "status": "static", + "note": "removes one listener, or every listener, from a resolved path's poller" + }, { "id": "node-builtin.fs.utimesSync", "kind": "node-builtin", @@ -1291,6 +1298,13 @@ "name": "fs.watch", "status": "static" }, + { + "id": "node-builtin.fs.watchFile", + "kind": "node-builtin", + "name": "fs.watchFile", + "status": "static", + "note": "stat polling of a resolved path with Stats listeners, in statement position; bigint: true is refused" + }, { "id": "node-builtin.fs.writeFileSync", "kind": "node-builtin", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json new file mode 100644 index 000000000..15ac693c1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-watch-listener-shapes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_watch.c b/packages/runtime/src/scr_watch.c index 0d5087961..d1730c75b 100644 --- a/packages/runtime/src/scr_watch.c +++ b/packages/runtime/src/scr_watch.c @@ -288,6 +288,12 @@ void scr_watcher_release_v(void *p) { scr_watcher_release((ScrWatcher *)p); } * filter. cb (nullable) moves in as the first listener. Result +1. */ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn, bool wants_name) { + /* getValidatedPath: a null byte throws ERR_INVALID_ARG_VALUE before the + * path reaches the platform call. */ + if (!scr_fs_path_ok(path, "path")) { + if (cb != NULL) scr_closure_release(cb); + return NULL; /* exception pending */ + } #ifdef SCR_HAVE_INOTIFY /* The wd IS the validation: Node's Linux throw comes straight from * inotify_add_watch's errno, the same message shape as the open below. */ @@ -542,7 +548,7 @@ void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb) { ScrWatchL *l = &w->ls[i - 1]; if (l->event != (uint8_t)event || l->cb != cb) continue; scr_closure_release(l->cb); - memmove(l + 1, l, (w->n - i) * sizeof *w->ls); + memmove(l, l + 1, (w->n - i) * sizeof *w->ls); w->n--; return; } diff --git a/packages/runtime/src/scr_watch_file.c b/packages/runtime/src/scr_watch_file.c index 6b40e5ed0..b1bee2826 100644 --- a/packages/runtime/src/scr_watch_file.c +++ b/packages/runtime/src/scr_watch_file.c @@ -32,6 +32,7 @@ #include "scr_runtime.h" #include +#include #include #include #include @@ -230,11 +231,31 @@ static void scr_watch_file_stop(ScrWatchFiler *p) { scr_watch_file_release(p); } +/* StatWatcher's validateUint32(interval, 'interval'): false after throwing + * Node's ERR_OUT_OF_RANGE RangeError. */ +static bool scr_watch_file_interval_ok(double interval) { + bool integer = isfinite(interval) && trunc(interval) == interval; + if (integer && interval >= 0 && interval <= 4294967295.0) return true; + char recv[48], msg[160]; + scr_num_received(interval, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"interval\" is out of range. It must be %s. Received %s", + integer ? ">= 0 && <= 4294967295" : "an integer", recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; +} + /* fs.watchFile(path, interval, persistent, listener) — the listener MOVES - * in. A second call on the same resolved path adds the listener to the - * existing poller and ignores its options (Node's rule). */ + * in. A path holding a null byte throws Node's ERR_INVALID_ARG_VALUE. A + * second call on the same resolved path adds the listener to the existing + * poller and ignores its options (Node's rule); a new poller first checks + * `interval` as a uint32, and registers nothing when that throws. */ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb, ScrWatchFileFn fn) { + if (!scr_fs_path_ok(path, "path")) { + scr_closure_release(cb); + return; /* exception pending */ + } ScrStr *resolved = scr_watch_file_resolve(path); ScrWatchFiler *existing = scr_watch_file_find(resolved); if (existing != NULL) { @@ -242,6 +263,11 @@ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosur scr_watch_file_add(existing, cb, fn); return; } + if (!scr_watch_file_interval_ok(interval)) { + scr_str_release(resolved); + scr_closure_release(cb); + return; /* exception pending */ + } ScrWatchFiler *p = malloc(sizeof *p); if (!p) { scr_str_release(resolved); @@ -275,8 +301,10 @@ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosur /* fs.unwatchFile(path[, listener]) — `cb` is borrowed (pointer identity); * NULL removes every listener on the resolved path. Removing the last one - * stops the poller. */ + * stops the poller. A path holding a null byte throws Node's + * ERR_INVALID_ARG_VALUE. */ void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) { + if (!scr_fs_path_ok(path, "path")) return; /* exception pending */ ScrStr *resolved = scr_watch_file_resolve(path); ScrWatchFiler *p = scr_watch_file_find(resolved); scr_str_release(resolved); @@ -299,16 +327,17 @@ void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) { } /* ── the runtime-provided listener adapters ─────────────────────────── - * The listener owns its +1 arguments (the universal convention - * scr_watch_thunk_event follows). */ + * The adapter receives +1 references and the listener owns those it takes + * (the universal convention scr_watch_thunk_event follows); the adapter + * releases the ones its listener has no parameter for. */ void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous) { - (void)current; - (void)previous; + scr_stats_release(current); + scr_stats_release(previous); ((void (*)(ScrClosure *))cb->fn)(cb); } void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous) { - (void)previous; + scr_stats_release(previous); ((void (*)(ScrClosure *, ScrStats *))cb->fn)(cb, current); } diff --git a/tests/corpus/fs-watch-file-polling.ts b/tests/corpus/fs-watch-file-polling.ts index 87cddf867..2282c7c6b 100644 --- a/tests/corpus/fs-watch-file-polling.ts +++ b/tests/corpus/fs-watch-file-polling.ts @@ -5,7 +5,9 @@ // sides zeroed, and the creation reports previous.size 0 and previous.mtimeMs 0. Two watchFile calls on one path share one poller; // unwatchFile(path, listener) removes one listener, unwatchFile(path) // removes the rest, and once no listener is left the poller stops and -// the process can exit. +// the process can exit. A new poller's interval must be a uint32 (a call on +// a watched path ignores its options), and a path holding a null byte +// throws for both calls. import * as fs from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -15,6 +17,26 @@ const existing = join(dir, "existing.txt"); const later = join(dir, "later.txt"); fs.writeFileSync(existing, "a"); +const invalid = join(dir, "invalid.txt"); +for (const interval of [-1, 1.5, Number.NaN, 2 ** 32]) { + try { + fs.watchFile(invalid, { interval }, () => {}); + console.log("interval", interval, "accepted"); + } catch (err) { + console.log("interval", interval, (err as NodeJS.ErrnoException).code, (err as Error).message); + } +} +try { + fs.watchFile("bad\0path", () => {}); +} catch (err) { + console.log("watchFile", (err as NodeJS.ErrnoException).code, (err as Error).message); +} +try { + fs.unwatchFile("bad\0path"); +} catch (err) { + console.log("unwatchFile", (err as NodeJS.ErrnoException).code, (err as Error).message); +} + let firstCalls = 0; const first = (current: fs.Stats, previous: fs.Stats): void => { firstCalls++; @@ -27,7 +49,7 @@ const second = (current: fs.Stats, previous: fs.Stats): void => { startMissingPhase(); }; fs.watchFile(existing, { interval: 20 }, first); -fs.watchFile(existing, { interval: 1000 }, second); +fs.watchFile(existing, { interval: -1 }, second); setTimeout(() => fs.writeFileSync(existing, "abcd"), 150); function startMissingPhase(): void { diff --git a/tests/corpus/fs-watch-listener-shapes.ts b/tests/corpus/fs-watch-listener-shapes.ts new file mode 100644 index 000000000..e6c399115 --- /dev/null +++ b/tests/corpus/fs-watch-listener-shapes.ts @@ -0,0 +1,91 @@ +// fs.watch and fs.watchFile listeners of each shape. watcher.off of a +// 'change' listener that is not the last one leaves the others, in order. +// A one-parameter listener typed any and a two-parameter listener whose +// eventType is any receive the event name as a string. fs.watchFile +// listeners with no parameter and with one parameter run once per change +// a poll sees, like the two-parameter form. Event counts and coalescing are +// deliberately unobserved (they differ between inotify, kqueue and +// FSEvents); each watchFile change waits for the previous one's event. +// A path holding a null byte throws before anything is watched. +import * as fs from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const dir = fs.mkdtempSync(join(tmpdir(), "scr-watch-shapes-")); +const file = join(dir, "data.txt"); +fs.writeFileSync(file, "a"); + +try { + fs.watch("bad\0path", () => {}).close(); + console.log("watch accepted"); +} catch (err) { + console.log("watch", (err as NodeJS.ErrnoException).code, (err as Error).message); +} + +const order: string[] = []; +let anyEvent: any = null; +let dynEvent: any = null; +let named = 0; +const watcher = fs.watch(file, (eventType: any) => { + if (anyEvent === null) anyEvent = eventType; +}); +const first = () => { + order.push("first"); +}; +const second = () => { + order.push("second"); +}; +const third = () => { + order.push("third"); +}; +watcher.on("change", first); +watcher.on("change", second); +watcher.on("change", third); +watcher.off("change", second); +watcher.on("change", (eventType: any, filename: any) => { + if (dynEvent === null) dynEvent = eventType; + if (filename !== null) named++; +}); + +const bound = setTimeout(() => { + console.log("timed out"); + process.exit(1); +}, 10000); +const touch = setInterval(() => fs.appendFileSync(file, "x"), 40); +const poll = setInterval(() => { + if (anyEvent === null || dynEvent === null || order.length < 2) return; + clearInterval(poll); + clearInterval(touch); + watcher.close(); + console.log("after off", order.slice(0, 2).join(",")); + console.log("any eventType", typeof anyEvent, anyEvent === "change" || anyEvent === "rename"); + console.log("dynamic eventType", typeof dynEvent, dynEvent === "change" || dynEvent === "rename"); + startPolling(); +}, 10); + +function startPolling(): void { + let zeroCalls = 0; + let oneCalls = 0; + let size = 0; + const zero = (): void => { + zeroCalls++; + }; + const one = (current: fs.Stats): void => { + oneCalls++; + size = current.size; + if (oneCalls < 3) { + setTimeout(() => fs.writeFileSync(file, "x".repeat(oneCalls + 10)), 30); + return; + } + setTimeout(() => { + console.log("watchFile calls", zeroCalls === oneCalls, oneCalls, "size", size); + fs.unwatchFile(file); + clearTimeout(bound); + fs.rmSync(dir, { recursive: true, force: true }); + console.log("done"); + }, 60); + }; + fs.watchFile(file, { interval: 20 }, zero); + fs.watchFile(file, { interval: 20 }, one); + setTimeout(() => fs.writeFileSync(file, "x".repeat(10)), 100); +} diff --git a/tests/fixtures/node-types/fenced.ts b/tests/fixtures/node-types/fenced.ts index 33cc56778..3fb41c490 100644 --- a/tests/fixtures/node-types/fenced.ts +++ b/tests/fixtures/node-types/fenced.ts @@ -32,10 +32,10 @@ ReadableStream.from(new Set([1, 2])); /* Members of SUPPORTED builtin modules beyond the lowered tables: they * typecheck under @types/node and fence with the module-qualified name — * calls and value reads alike. */ -import { watchFile } from "fs"; +import { openAsBlob } from "fs"; import { EventEmitter, on } from "node:events"; import { win32 } from "path"; -watchFile("x", () => {}); +openAsBlob("x"); on(new EventEmitter(), "tick"); console.log(win32.sep); /* URL getters and serialization lower under @types/node, but object-URL diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index ada6231d4..be6452270 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -43,18 +43,18 @@ fenced.ts:31:21 - error SC2020: 'ReadableStream.from() over Set iterable hint: arrays, Uint8Array, and strings are supported — spread other synchronous iterables into an array first: [...iterable] -fenced.ts:38:1 - error SC2020: 'fs.watchFile' is typed by @types/node but has no scriptc lowering yet +fenced.ts:38:1 - error SC2020: 'fs.openAsBlob' is typed by @types/node but has no scriptc lowering yet 37 | import { win32 } from "path"; - 38 | watchFile("x", () => {}); - | ^~~~~~~~~~~~~~~~~~~~~~~~ + 38 | openAsBlob("x"); + | ^~~~~~~~~~~~~~~ 39 | on(new EventEmitter(), "tick"); hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations) fenced.ts:39:1 - error SC2020: 'events.on' is typed by @types/node but has no scriptc lowering yet - 38 | watchFile("x", () => {}); + 38 | openAsBlob("x"); 39 | on(new EventEmitter(), "tick"); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 40 | console.log(win32.sep); diff --git a/tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json b/tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json new file mode 100644 index 000000000..c5606ebea --- /dev/null +++ b/tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 9200 } diff --git a/tests/harness/fs-watch-leaks.test.ts b/tests/harness/fs-watch-leaks.test.ts new file mode 100644 index 000000000..424d6ca56 --- /dev/null +++ b/tests/harness/fs-watch-leaks.test.ts @@ -0,0 +1,64 @@ +/* fs.watch and fs.watchFile listeners under LeakSanitizer. A listener owns + * the arguments its adapter hands it, and the adapter releases the ones the + * listener has no parameter for, including the event string it boxes for a + * dynamic parameter and the Stats a watchFile listener of zero or one + * parameter leaves out. The sanitized lanes otherwise run with + * detect_leaks=0, so this runs the watch programs with LeakSanitizer + * explicitly on. Linux only (Apple's ASan has no LeakSanitizer); runs in + * the SCRIPTC_SAN=1 lane. */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +const programs = [ + "tests/corpus/fs-watch-listener-shapes.ts", + "tests/corpus/fs-watch-file-polling.ts", +]; + +describe.skipIf(!sanitize || process.platform !== "linux")( + "fs watchers under LeakSanitizer", + () => { + test.each(programs)("%s reports no leaks", { timeout: 300_000 }, async (program) => { + const file = join(repoRoot, program); + const key = createHash("sha256") + .update(file) + .update(readFileSync(file)) + .update("san") + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, `fs-watch-leaks-${key}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize: true, + backend: "llvm", + }); + if (!result.ok) { + throw new Error( + "program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const run = await execFileAsync(result.binaryPath, [], { + env: { ...process.env, ASAN_OPTIONS: "detect_leaks=1" }, + maxBuffer: 16 * 1024 * 1024, + }).catch((error: { code?: number; stdout?: string; stderr?: string }) => error); + expect(run.stderr).toBe(""); + expect("code" in run ? run.code : 0).toBe(0); + const expected = await execFileAsync(process.execPath, ["--no-warnings", file], { + maxBuffer: 16 * 1024 * 1024, + }); + expect(run.stdout).toBe(expected.stdout); + }); + }, +); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index db1faab9b..642c60a5c 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -47,6 +47,7 @@ const features: NativeLinkFeatures = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/tests/harness/self-hosting-runtime-pack.test.ts b/tests/harness/self-hosting-runtime-pack.test.ts index 859f820da..16f9e23fd 100644 --- a/tests/harness/self-hosting-runtime-pack.test.ts +++ b/tests/harness/self-hosting-runtime-pack.test.ts @@ -44,6 +44,7 @@ const base: NativeLinkFeatures = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, From 8537c41171e982732c5a92bb18e70a84187af024 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:05:14 -0500 Subject: [PATCH 039/193] Keep the receiver bracket around stream completion calls The stream completion closures carried SCR_CLOSURE_KIND_RECEIVER_FREE, so compiled callers skipped the receiver bracket around cb(). A completion runs user code synchronously (the write(chunk, callback) callback, the next queued _write), and that code reads the ambient receiver, so the flag is removed and the completions bind `this` as any unbound call does. Corpus programs 8531 and 8532 call a saved completion from inside another object's method, from JavaScript and from a TypeScript class method that JavaScript calls, and check `this` in the callbacks it runs against Node. --- ...31-stream-saved-callback-receiver.mjs.json | 6 ++++ .../main.ts.json | 7 ++++ packages/runtime/src/scr_closure.h | 3 +- packages/runtime/src/scr_stream.c | 1 - .../8531-stream-saved-callback-receiver.mjs | 34 +++++++++++++++++++ .../label.js | 13 +++++++ .../main.ts | 32 +++++++++++++++++ 7 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8531-stream-saved-callback-receiver.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8532-stream-saved-callback-receiver/main.ts.json create mode 100644 tests/corpus/8531-stream-saved-callback-receiver.mjs create mode 100644 tests/corpus/8532-stream-saved-callback-receiver/label.js create mode 100644 tests/corpus/8532-stream-saved-callback-receiver/main.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8531-stream-saved-callback-receiver.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8531-stream-saved-callback-receiver.mjs.json new file mode 100644 index 000000000..ab3125e3a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8531-stream-saved-callback-receiver.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8531-stream-saved-callback-receiver.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8532-stream-saved-callback-receiver/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8532-stream-saved-callback-receiver/main.ts.json new file mode 100644 index 000000000..73ad7e0e2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8532-stream-saved-callback-receiver/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8532-stream-saved-callback-receiver/label.js", + "/tests/corpus/8532-stream-saved-callback-receiver/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_closure.h b/packages/runtime/src/scr_closure.h index ccf3bc529..89f0378af 100644 --- a/packages/runtime/src/scr_closure.h +++ b/packages/runtime/src/scr_closure.h @@ -95,8 +95,7 @@ void scr_box_set_ref(ScrBox *b, void *v); /* releases old, owns new */ #define SCR_CLOSURE_KIND_CHECKED_ADAPTER 8u /* a checked callable adapter: the callable is in caps[0] */ /* The body never observes the ambient receiver (the `this` an unbound call * binds), so compiled callers skip the receiver bracket around an indirect - * call. Set by the compiler, and by the runtime on the stream completion - * callbacks, whose thunks never read it; the kind proper is + * call. Set only by the compiler; the kind proper is * function_kind & SCR_CLOSURE_KIND_MASK. */ #define SCR_CLOSURE_KIND_RECEIVER_FREE 16u #define SCR_CLOSURE_KIND_MASK 15u diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 1f07d167b..590199468 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -541,7 +541,6 @@ void scr_stream_release_v(void *s) { scr_stream_release(s); } * the stream. Returns +1. */ static ScrClosure *scr_stream_done_new(ScrStream *s, void *fn) { ScrClosure *c = scr_closure_new(fn, 1); - c->function_kind |= SCR_CLOSURE_KIND_RECEIVER_FREE; /* the completion thunks never read `this` */ ScrBox *bx = scr_box_new_obj(scr_stream_retain_v, scr_stream_release_v, scr_stream_trace); c->caps[0] = bx; scr_box_set_ref(bx, scr_stream_retain(s)); diff --git a/tests/corpus/8531-stream-saved-callback-receiver.mjs b/tests/corpus/8531-stream-saved-callback-receiver.mjs new file mode 100644 index 000000000..886b4d29d --- /dev/null +++ b/tests/corpus/8531-stream-saved-callback-receiver.mjs @@ -0,0 +1,34 @@ +// A write's saved completion called later inside another object's method: +// the write(chunk, callback) callback it runs is called unbound, so its +// `this` is undefined (module code is strict), and the next queued write's +// _write sees the stream as `this`. +import { Writable } from "node:stream"; + +let saved = null; +const seen = []; +const label = (self) => (self === undefined ? "undefined" : self === w ? "stream" : self === holder ? "holder" : typeof self); +const w = new Writable({ + objectMode: true, + write(chunk, _enc, cb) { + seen.push(`write ${chunk.n} this=${label(this)}`); + if (chunk.n === 1) saved = cb; + else cb(); + }, +}); +const holder = { + run() { + saved(); + }, +}; +w.write({ n: 1 }, function () { + seen.push(`callback 1 this=${label(this)}`); +}); +w.write({ n: 2 }, function () { + seen.push(`callback 2 this=${label(this)}`); +}); +setTimeout(() => { + holder.run(); + w.end(() => { + for (const line of seen) console.log(line); + }); +}, 0); diff --git a/tests/corpus/8532-stream-saved-callback-receiver/label.js b/tests/corpus/8532-stream-saved-callback-receiver/label.js new file mode 100644 index 000000000..ab824f44d --- /dev/null +++ b/tests/corpus/8532-stream-saved-callback-receiver/label.js @@ -0,0 +1,13 @@ +// write(chunk, callback) callbacks that report their `this`; module code is +// strict, so an unbound call sees undefined. +export function makeCallback(seen, name, holder) { + return function () { + seen.push(`${name} this=${this === undefined ? "undefined" : this === holder ? "holder" : typeof this}`); + }; +} + +// Calls holder.run() as a method from JavaScript: run's own call of the +// saved completion happens while this call's receiver is the holder. +export function callRun(holder) { + holder.run(); +} diff --git a/tests/corpus/8532-stream-saved-callback-receiver/main.ts b/tests/corpus/8532-stream-saved-callback-receiver/main.ts new file mode 100644 index 000000000..dccb65c08 --- /dev/null +++ b/tests/corpus/8532-stream-saved-callback-receiver/main.ts @@ -0,0 +1,32 @@ +// A saved write completion called inside a class method, which JavaScript +// calls as holder.run(), runs the write(chunk, callback) callbacks unbound: +// a JavaScript callback sees `this` as undefined, not the method's +// receiver. +import { Writable } from "node:stream"; +import { callRun, makeCallback } from "./label.js"; + +let saved: ((error?: Error | null) => void) | null = null; +const seen: string[] = []; +const w = new Writable({ + objectMode: true, + write(chunk: { n: number }, _enc, cb) { + seen.push(`write ${chunk.n}`); + if (chunk.n === 1) saved = cb; + else cb(); + }, +}); +class Holder { + label = "holder"; + run(): void { + if (saved !== null) saved(); + } +} +const holder = new Holder(); +w.write({ n: 1 }, makeCallback(seen, "callback 1", holder)); +w.write({ n: 2 }, makeCallback(seen, "callback 2", holder)); +setTimeout(() => { + callRun(holder); + w.end(() => { + for (const line of seen) console.log(line); + }); +}, 0); From ad9fa7f510917f81f4f0e23ba59057a3e6928ebc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:07:17 -0500 Subject: [PATCH 040/193] Compile the functions a sparse profile never ran as cold code A profile merged with --sparse has no record of the functions its training never ran, so a build with it optimized them as without a profile, and on tsc-ts they are 72% of the program's IR. The block-count file a recording build writes now also names those functions, with an entry count of zero and no blocks. A --profile-use build that reads the file gives such a function an entry count of 0 and the cold attribute, as it does for a mismatched function the file records as never run, so LLVM optimizes it for size and places it with the other unlikely code. A function the file does not name, such as one added since the profile was collected, keeps no counts. --- docs/content/docs/cli.mdx | 4 +- native/llvm-codegen/src/emit.cpp | 24 +++- native/llvm-codegen/src/profile-blocks.cpp | 10 ++ native/llvm-codegen/src/profile-blocks.h | 2 + packages/compiler/test/native-profile.test.ts | 114 +++++++++++++++++- 5 files changed, 146 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index f439edf52..fc3ae2790 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -309,14 +309,14 @@ A profile that already holds context-sensitive counts cannot start a second phas ### Block counts -A profile matches a function only while the function's control flow hashes as it did when the profile was collected, so a change to the program or to scriptc leaves the changed functions without counts. A block-count file beside the profile, with its name and a `.blocks` extension in place of `.profdata` (`app.blocks` beside `app.profdata`), describes each function by the labels of its blocks instead. `--profile-use` reads it when it exists and annotates the functions whose control flow changed (the mismatched ones; a function the profile has no record of keeps no counts): blocks that keep their place in the function take their recorded counts, and the counts of the other blocks follow from the counts of the blocks and edges around them. The report then adds a line, and the warning counts only the functions that remain without counts: +A profile matches a function only while the function's control flow hashes as it did when the profile was collected, so a change to the program or to scriptc leaves the changed functions without counts. A block-count file beside the profile, with its name and a `.blocks` extension in place of `.profdata` (`app.blocks` beside `app.profdata`), describes each function by the labels of its blocks instead. `--profile-use` reads it when it exists and annotates the functions whose control flow changed (the mismatched ones): blocks that keep their place in the function take their recorded counts, and the counts of the other blocks follow from the counts of the blocks and edges around them. The file also names the functions that never ran while the profile was collected, which a profile merged with `--sparse` has no record of. Those functions are compiled as cold code (an entry count of 0, optimized for size and placed with the other unlikely code), which shortens the build. A function the file does not name, for example one added since the profile was collected, keeps no counts and is optimized as without a profile. The report then adds a line, and the warning counts only the functions that remain without counts: ``` scriptc: profile app-cs.profdata: 0 functions annotated, 1 mismatched, 0 without a record scriptc: profile app-cs.profdata: 1 function without profile counts annotated from app-cs.blocks ``` -A `--profile-use` build writes the block counts of the functions its profile annotates when the `SCRIPTC_RECORD_PROFILE_BLOCKS` environment variable names a path; such a build reuses no cached artifacts. For a context-sensitive profile, record them in the second phase, which the first-phase profile annotates: +A `--profile-use` build writes the block counts of the functions its profile annotates, and the names of the functions it has no record of, when the `SCRIPTC_RECORD_PROFILE_BLOCKS` environment variable names a path; such a build reuses no cached artifacts. For a context-sensitive profile, record them in the second phase, which the first-phase profile annotates: ```console $ SCRIPTC_RECORD_PROFILE_BLOCKS=app-cs.blocks scriptc build app.ts --optimization=speed --profile-use app.profdata --profile-generate profiles-cs/%p.profraw -o app-instrumented-cs diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index 17d69be72..3eeedd623 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -374,23 +374,34 @@ bool hasProfileCounts(const Function &F) { // Runs right after the profile annotation, before inlining and dead code // elimination change the functions it annotated: counts them and records -// their block counts, then annotates the functions whose control flow -// changed from FallbackCounts. A function the profile has no record of keeps -// no counts, as without block counts. Returns the analyses that remain valid. +// their block counts, then annotates from FallbackCounts the functions whose +// control flow changed, and the functions the profile has no record of that +// FallbackCounts records as never run. A sparse profile leaves out the +// functions its training never ran; the block counts name them, so they get +// an entry count of 0 and LLVM optimizes them as cold code, as a profile with +// zero counts would make it. A function neither names keeps no counts, as +// without block counts: it was added after the profile was collected. +// Returns the analyses that remain valid. PreservedAnalyses afterProfileAnnotation(Module &M, ProfileMatch &Match) { std::vector Mismatched; + std::vector Unrecorded; for (Function &F : M) { if (F.isDeclaration()) continue; if (!hasProfileCounts(F)) { if (hasHashMismatchTag(F)) Mismatched.push_back(&F); + else + Unrecorded.push_back(&F); continue; } ++Match.Annotated; if (Recorder) Recorder->add(F); } + if (Recorder) + for (Function *F : Unrecorded) + Recorder->addNeverRan(*F); if (!FallbackCounts) return PreservedAnalyses::all(); ProfileSummaryInfo Summary(M); @@ -403,6 +414,13 @@ PreservedAnalyses afterProfileAnnotation(Module &M, ProfileMatch &Match) { ++Match.Recovered; Preserved = PreservedAnalyses::none(); } + for (Function *F : Unrecorded) { + auto Record = FallbackCounts->find(F->getName()); + if (Record == FallbackCounts->end() || Record->second.EntryCount != 0) + continue; + annotateFromBlocks(*F, Record->second, Summary); + Preserved = PreservedAnalyses::none(); + } return Preserved; } diff --git a/native/llvm-codegen/src/profile-blocks.cpp b/native/llvm-codegen/src/profile-blocks.cpp index 4c94577a3..6504f585b 100644 --- a/native/llvm-codegen/src/profile-blocks.cpp +++ b/native/llvm-codegen/src/profile-blocks.cpp @@ -424,6 +424,16 @@ void annotateFromBlocks(Function &F, const FunctionBlocks &Record, markFrequency(F, EntryCount, MaxCount, Summary); } +void BlockRecorder::addNeverRan(const Function &F) { + std::string Text; + raw_string_ostream OS(Text); + OS << "f "; + writeEscaped(OS, F.getName()); + OS << " 0 0\n"; + std::lock_guard Guard(Lock); + Records.emplace(F.getName().str(), std::move(Text)); +} + void BlockRecorder::add(const Function &F) { std::optional Entry = F.getEntryCount(); if (!Entry || Entry->isSynthetic()) diff --git a/native/llvm-codegen/src/profile-blocks.h b/native/llvm-codegen/src/profile-blocks.h index 1db71b53e..9368bcbe2 100644 --- a/native/llvm-codegen/src/profile-blocks.h +++ b/native/llvm-codegen/src/profile-blocks.h @@ -71,6 +71,8 @@ class BlockRecorder { public: // Records F's block counts, which the profile annotation just gave it. void add(const llvm::Function &F); + // Records F, which the profile has no record of, as never run. + void addNeverRan(const llvm::Function &F); // Writes the file through a private sibling; returns an error message on // failure. std::optional write(llvm::StringRef Path); diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts index e860d4010..6744a0973 100644 --- a/packages/compiler/test/native-profile.test.ts +++ b/packages/compiler/test/native-profile.test.ts @@ -130,6 +130,76 @@ function expectedExit(argc: number, edit = 0): number { return Number(total % 251n); } +/** A program whose `rare` runs only with more than ten arguments, which + * training never passes; `added` (with `extra`) is defined after the + * profile was collected. */ +function rareProgram(extra = false): string { + const lines = ["; profiled program with a function training never runs"]; + const helpers: [string, number][] = [ + ["hot", 5], + ["rare", 9], + ...(extra ? [["added", 11]] : []), + ] as [string, number][]; + for (const [name, factor] of helpers) + lines.push( + `define internal i64 @${name}(i64 %n) noinline {`, + "entry:", + ` %m = mul i64 %n, ${factor}`, + " %bit = and i64 %m, 1", + " %odd = icmp ne i64 %bit, 0", + " br i1 %odd, label %a, label %b", + "a:", + ` %x = add i64 %m, ${factor}`, + " br label %c", + "b:", + " %y = sub i64 %m, 1", + " br label %c", + "c:", + " %r = phi i64 [ %x, %a ], [ %y, %b ]", + " ret i64 %r", + "}", + ); + lines.push( + "define i32 @main(i32 %argc, ptr %argv) {", + "entry:", + " %w = sext i32 %argc to i64", + " %h = call i64 @hot(i64 %w)", + " %many = icmp ugt i32 %argc, 10", + " br i1 %many, label %rare.call, label %done", + "rare.call:", + " %r = call i64 @rare(i64 %h)", + ...(extra ? [" %e = call i64 @added(i64 %r)"] : []), + " br label %done", + "done:", + ` %v = phi i64 [ %h, %entry ], [ ${extra ? "%e" : "%r"}, %rare.call ]`, + " %low = and i64 %v, 127", + " %code = trunc i64 %low to i32", + " ret i32 %code", + "}", + ); + return lines.join("\n") + "\n"; +} + +function rareExit(argc: number): number { + const step = (n: number, factor: number) => { + const m = n * factor; + return m % 2 === 1 ? m + factor : m - 1; + }; + let v = step(argc, 5); + if (argc > 10) v = step(v, 9); + return v & 127; +} + +/** The section that holds `name` in an object file. */ +async function sectionOf(object: string, name: string): Promise { + const { stdout } = await execFileAsync("objdump", ["-t", object]); + return stdout + .split("\n") + .map((line) => line.trim().split(/\s+/)) + .find((fields) => fields.at(-1) === name) + ?.at(-3); +} + const INLINED = 6; /** A program whose helpers are too large for the inliner that runs before the @@ -263,8 +333,14 @@ async function run(executable: string, argc: number, env: NodeJS.ProcessEnv = {} } } -async function merge(output: string, inputs: string[]) { - return execFileAsync(helper!, ["merge-profiles", "--output", output, ...inputs]); +async function merge(output: string, inputs: string[], sparse = false) { + return execFileAsync(helper!, [ + "merge-profiles", + "--output", + output, + ...(sparse ? ["--sparse"] : []), + ...inputs, + ]); } /** An instrumented build of `source` run with 1 and 3 arguments, its raw @@ -275,6 +351,7 @@ async function collect( outputs: number, name: string, expected: (argc: number) => number = expectedExit, + sparse = false, ) { const raw = join(dir, `${name}-raw`); const objects = await emit(dir, source, { outputs, name, generate: join(raw, "%p.profraw") }); @@ -284,7 +361,7 @@ async function collect( const profiles = (await readdir(raw)).map((file) => join(raw, file)); expect(profiles).toHaveLength(2); const profile = join(dir, `${name}.profdata`); - await merge(profile, profiles); + await merge(profile, profiles, sparse); return { profile, raws: profiles }; } @@ -563,6 +640,37 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { }); }); + test("block counts mark the functions a sparse profile never ran as cold", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-blocks-never-")); + dirs.push(dir); + const { profile } = await collect(dir, rareProgram(), 1, "train", rareExit, true); + const blocks = join(dir, "train.blocks"); + await emit(dir, rareProgram(), { + outputs: 1, + name: "recorded", + use: profile, + record: blocks, + }); + const text = await readFile(blocks, "utf8"); + expect(text).toMatch(/^f hot [1-9]\d* [1-9]\d*$/m); + expect(text).toMatch(/^f rare 0 0$/m); + + // Without the block counts rare keeps no counts; with them it is cold + // code. added, which the block counts do not name, keeps no counts. + const [plain] = await emit(dir, rareProgram(), { outputs: 1, name: "plain", use: profile }); + const [cold] = await emit(dir, rareProgram(true), { + outputs: 1, + name: "cold", + use: profile, + blocks, + }); + expect(await sectionOf(plain!, "rare")).toBe(".text"); + expect(await sectionOf(cold!, "rare")).toMatch(/^\.text\.unlikely/); + expect(await sectionOf(cold!, "added")).toBe(".text"); + expect(await sectionOf(cold!, "hot")).toMatch(/^\.text\.hot/); + expect(await run(await link(dir, [cold!], "cold"), 2)).toBe(rareExit(2)); + }); + test("block counts need a profile and a file that parses", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-blocks-error-")); dirs.push(dir); From ed46427ea7913b4f6244f776aa24c24ae678395e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:41:44 -0500 Subject: [PATCH 041/193] Read /proc/self/maps once per glibc thread heap A worker thread's large blocks come from glibc heaps, and the first large block of each heap made the allocator check /proc/self/maps before advising the heap huge pages. The check was skipped only for the heap the thread advised last, so threads that switched between heaps read the maps again at every switch: 9,000 to 11,000 reads in a type check of T3 web with four checkers. A process-wide table now records every heap decided, and each heap is read once. --- packages/runtime/src/scr_alloc.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 13b83a396..4e179ed91 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -640,6 +640,30 @@ static _Atomic(uintptr_t) scr_sa_heap_page; /* the page size, read with the mode #define SCR_SA_THREAD_TRIM_THRESHOLD (8 << 20) static _Thread_local size_t scr_sa_thread_large_bytes; static _Thread_local uintptr_t scr_sa_thread_heap; /* the heap this thread advised last */ +/* The heaps decided already, process-wide: advised, or found not to be a + * whole glibc heap. Each heap is checked against /proc/self/maps once: the + * threads of a type check switch between heaps often, and reading the maps + * at each switch cost thousands of reads of a file that grows with the + * process's mappings. A full table decides again; a heap that glibc maps + * again at an address decided before keeps the pages it gets. */ +#define SCR_SA_HEAPS_SEEN 1024u +static _Atomic(uintptr_t) scr_sa_heaps_seen[SCR_SA_HEAPS_SEEN]; + +/* Whether `heap` was decided already; records it when it was not. */ +static bool scr_sa_heap_seen(uintptr_t heap) { + size_t h = (size_t)(((uint64_t)(heap / SCR_SA_GLIBC_HEAP) * 0x9E3779B97F4A7C15ull) >> 54) & (SCR_SA_HEAPS_SEEN - 1); + for (size_t k = 0; k < SCR_SA_HEAPS_SEEN; k++, h = (h + 1) & (SCR_SA_HEAPS_SEEN - 1)) { + uintptr_t cur = atomic_load_explicit(&scr_sa_heaps_seen[h], memory_order_relaxed); + if (cur == heap) return true; + if (cur == 0) { + if (atomic_compare_exchange_strong_explicit(&scr_sa_heaps_seen[h], &cur, heap, memory_order_relaxed, + memory_order_relaxed)) + return false; + if (cur == heap) return true; + } + } + return false; +} static _Atomic(int) scr_sa_thread_heaps; /* 0 undecided, 1 advised, 2 off */ extern void *__libc_malloc(size_t); @@ -716,6 +740,7 @@ static void scr_sa_thread_heap_note(void *p, size_t n) { uintptr_t heap = (uintptr_t)p & ~(SCR_SA_GLIBC_HEAP - 1); if (heap == scr_sa_thread_heap) return; scr_sa_thread_heap = heap; + if (scr_sa_heap_seen(heap)) return; if (scr_sa_glibc_heap_mapped(heap) && mprotect((void *)heap, SCR_SA_GLIBC_HEAP, PROT_READ | PROT_WRITE) == 0) (void)madvise((void *)heap, SCR_SA_GLIBC_HEAP, MADV_HUGEPAGE); } From 7329f07d36197606434132898e699c0f00bee72b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:42:35 -0500 Subject: [PATCH 042/193] Match V8's default locale and calendar in the ICU formatters, and build Intl programs without Zig from the runtime pack - scr_intl.c resolves the default locale as V8 does: ICU's POSIX locale (LC_ALL=C, C.UTF-8, POSIX) becomes en-US, so numbers keep their grouping separators, and other defaults round-trip through their BCP 47 tag. The number formatter, the date pattern generator, the date formatter and the default word segmenter use that locale. - The date formatter moves the Gregorian calendar's Julian cutover to the start of ECMAScript time, so dates before October 1582 print their proleptic Gregorian fields, as in Node. - icu.numFormatDefault, icu.dateLocaleString and icu.dateLocaleTimeString are in the may-throw set: a missing ICU data file throws an Error into the surrounding catch. - `x.toLocaleString(undefined)` reads the receiver before the argument runs. - The linux-x64-gnu runtime pack ships the ICU headers scr_intl.c includes beside its ICU archive (manifest `headers`, verified like the other artifacts). The native compiler's sanitized build compiles scr_intl.c against them and refuses with SC3003 when they are missing or changed. The C-source path (sanitized builds) links the pack's ICU archive and uses those headers, instead of building ICU with Zig's C++ driver. - The macOS pack omits scr_intl.c, scr_intl.h declares ScrDyn, and the library-mode teachings test uses a locale argument, which still refuses with SC2020. Corpus programs pin the C and C.UTF-8 locales, pre-1582 dates and the receiver order against Node. intl-missing-data.test.ts covers the throw. sanitizer.test.ts covers the header lookup. --- .../compiler/src/backend/native/contracts.ts | 5 + .../compiler/src/backend/native/executable.ts | 37 ++++---- .../compiler/src/backend/runtime-pack-core.ts | 6 ++ packages/compiler/src/backend/runtime-pack.ts | 30 +++++- .../src/frontend/lowering/lower-calls.ts | 14 ++- packages/compiler/src/index.ts | 54 ++++++++--- packages/compiler/src/ir/builtin-effects.ts | 5 + .../compiler/src/ir/builtin-signatures.ts | 6 +- .../compiler/src/native/sanitizer.test.ts | 92 ++++++++++++++++++- packages/compiler/src/native/sanitizer.ts | 61 ++++++++++-- .../intl-tolocalestring-c-utf8-locale.ts.json | 6 ++ .../intl-tolocalestring-posix-locale.ts.json | 6 ++ .../runtime-pack-matrix.mjs | 6 +- .../runtime-pack-common/scripts/build.mjs | 42 ++++++++- .../runtime-pack-common/scripts/verify.mjs | 1 + packages/runtime/src/scr_intl.c | 61 ++++++++++-- packages/runtime/src/scr_intl.h | 2 + packages/runtime/vendor/README.md | 2 + .../intl-date-tolocalestring-default.ts | 11 +++ .../intl-number-tolocalestring-default.ts | 11 ++- .../intl-tolocalestring-c-utf8-locale.ts | 14 +++ .../intl-tolocalestring-posix-locale.ts | 14 +++ .../intl-tolocalestring-c-utf8-locale.ts.json | 1 + .../intl-tolocalestring-posix-locale.ts.json | 1 + tests/harness/intl-missing-data.test.ts | 73 +++++++++++++++ tests/harness/library-mode.test.ts | 2 +- 26 files changed, 501 insertions(+), 62 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-c-utf8-locale.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-posix-locale.ts.json create mode 100644 tests/corpus/intl-tolocalestring-c-utf8-locale.ts create mode 100644 tests/corpus/intl-tolocalestring-posix-locale.ts create mode 100644 tests/harness/corpus-costs/intl-tolocalestring-c-utf8-locale.ts.json create mode 100644 tests/harness/corpus-costs/intl-tolocalestring-posix-locale.ts.json create mode 100644 tests/harness/intl-missing-data.test.ts diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 17ba54345..f6eb3572e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -89,6 +89,11 @@ export interface CcOptions { * executable gets icudt78l.dat next to it. Every engine-free form keeps * linking none of it. */ intl?: boolean; + /** With `intl`: the installed runtime pack's ICU archive and the include + * root of the ICU headers it ships. The C-source path then links that + * archive and compiles scr_intl.c against those headers, instead of + * building ICU from its fetched release with Zig's C++ driver. */ + icuPack?: { archive: string; include: string }; /** The program uses node:assert (index.ts detects assert.* libCalls on * the IR): compiles scr_assert.c — the zlib gating precedent, so * assert-free binaries keep their exact size class. scr_regex.c calls diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 2ca1ff714..c4750ba06 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -128,8 +128,9 @@ interface RuntimeUnitContext { tlsArchive: string | null; brotliArchive: string | null; icuArchive: string | null; - /** The fetched ICU release root, for scr_intl.c's headers. */ - icuSourceDir: string | null; + /** The include roots of the ICU headers scr_intl.c compiles against: the + * runtime pack's, or the fetched release's common/ and i18n/. */ + icuIncludeDirs: readonly string[]; } /** An optional runtime input: runtime sources compiled when `when` holds, @@ -252,18 +253,15 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ : [], ), // Intl over ICU (scr_intl.c): ICU's C API only, at the default locale, - // reading its data file from beside the executable. The ICU headers are - // ICU's own source tree (common/unicode/*.h for the core API, and - // i18n/unicode/*.h for udat.h and unumberformatter.h), so the include - // paths are vendor inputs rather than a copied header set. + // reading its data file from beside the executable. The ICU headers come + // from the installed runtime pack beside its archive, or, when ICU is + // built from its fetched release, from that source tree (common/unicode + // for the core API, i18n/unicode for udat.h and unumberformatter.h). unitArgs((u) => - u.icuSourceDir === null || u.icuArchive === null + u.icuIncludeDirs.length === 0 || u.icuArchive === null ? [] : [ - "-I", - join(u.icuSourceDir, "source", "common"), - "-I", - join(u.icuSourceDir, "source", "i18n"), + ...u.icuIncludeDirs.flatMap((dir) => ["-I", dir]), u.rt(join(u.rtDir, "scr_intl.c")), u.icuArchive, ], @@ -713,8 +711,14 @@ export async function compileCInternal( let brotliArchive = brotli ? brotliArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot) : null; - let icuArchive = icu ? icuArchivePath(driver, vendorBuildIdentity, vendorCacheRoot) : null; - let icuSourceDir: string | null = null; + // The installed runtime pack's ICU archive is the one this path would + // build (uninstrumented either way), so a pack that ships it and its + // headers spares the Zig C++ build and the source download. + const icuPack = icu ? (opts.icuPack ?? null) : null; + let icuArchive = icu + ? (icuPack?.archive ?? icuArchivePath(driver, vendorBuildIdentity, vendorCacheRoot)) + : null; + let icuIncludeDirs: readonly string[] = icuPack === null ? [] : [icuPack.include]; // ours would collide; see scr_regex.c) — the standalone objects are for // static builds only. @@ -769,7 +773,7 @@ export async function compileCInternal( : await stageVendorInputs(materialize, join(stageRoot, "brotli")); brotliArchive = paths[0]!; } - if (icu) { + if (icu && icuPack === null) { const cachedArchive = icuArchivePath(driver, vendorBuildIdentity, materializeCacheRoot); protectCachedArtifact(cacheWarmPaths, cachedArchive); const materialize = async (): Promise => [ @@ -780,7 +784,8 @@ export async function compileCInternal( ? await materialize() : await stageVendorInputs(materialize, join(stageRoot, "icu")); icuArchive = paths[0]!; - icuSourceDir = await ensureIcuSources(materializeCacheRoot); + const icuSource = join(await ensureIcuSources(materializeCacheRoot), "source"); + icuIncludeDirs = [join(icuSource, "common"), join(icuSource, "i18n")]; } if (regex) { for (const object of lreObjectPaths( @@ -870,7 +875,7 @@ export async function compileCInternal( tlsArchive, brotliArchive, icuArchive, - icuSourceDir, + icuIncludeDirs, }), // A .ll program TU (the LLVM backend) deliberately carries no target // triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index a0f6b289b..762af3b36 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -79,6 +79,10 @@ export interface RuntimePackManifest { * MiB) is the only one; Intl programs copy it, every other program links * neither ICU nor its data. */ data?: RuntimePackArtifact[]; + /** C headers under one include root (`/unicode/*.h`): the ICU C API + * headers scr_intl.c includes, which a sanitized build compiles that unit + * against (native/sanitizer.ts). Only a pack that ships ICU lists them. */ + headers?: RuntimePackArtifact[]; system_libraries: { name: string; predicate: RuntimePredicate }[]; licenses: { path: string; license: string }[]; } @@ -252,6 +256,8 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest { }) || (manifest.data !== undefined && (!Array.isArray(manifest.data) || !manifest.data.every(validArtifact))) || + (manifest.headers !== undefined && + (!Array.isArray(manifest.headers) || !manifest.headers.every(validArtifact))) || !Array.isArray(manifest.system_libraries) || !manifest.system_libraries.every((raw) => { const library = object(raw); diff --git a/packages/compiler/src/backend/runtime-pack.ts b/packages/compiler/src/backend/runtime-pack.ts index ef452f58d..c83a3ceb4 100644 --- a/packages/compiler/src/backend/runtime-pack.ts +++ b/packages/compiler/src/backend/runtime-pack.ts @@ -42,6 +42,9 @@ export interface RuntimePackSelection { archives: string[]; /** Files the executable loads at run time, installed beside it. */ dataFiles: string[]; + /** The ICU headers an Intl selection ships (manifest `headers`), all under + * `/unicode/`, verified like the other artifacts. */ + headerFiles: string[]; systemLibraries: string[]; dependencyPaths: string[]; /** Exact installed inputs observed while the selected artifacts were verified. */ @@ -49,6 +52,7 @@ export interface RuntimePackSelection { selectedRuntimeArtifacts: RuntimePackArtifact[]; selectedArchiveArtifacts: RuntimePackArtifact[]; selectedDataArtifacts: RuntimePackArtifact[]; + selectedHeaderArtifacts: RuntimePackArtifact[]; } async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise { @@ -213,19 +217,26 @@ export async function loadRuntimePack(options: { const selectedVariants = selected.runtime; const selectedArchives = selected.archives; const selectedData = selected.data; - const selectedArtifactPaths = [...selectedVariants, ...selectedArchives, ...selectedData].map( - (artifact) => join(root, artifact.path), - ); + // The C-source path compiles scr_intl.c against these headers and links + // the pack's ICU archive (index.ts runtimePackIcuInputs). + const selectedHeaders = features.intl === true ? (manifest.headers ?? []) : []; + const selectedArtifactPaths = [ + ...selectedVariants, + ...selectedArchives, + ...selectedData, + ...selectedHeaders, + ].map((artifact) => join(root, artifact.path)); let artifactDependencies: NativeArtifactDependency[]; try { artifactDependencies = await snapshotDependencies(selectedArtifactPaths); } catch { throw new RuntimePackError("runtime pack artifact set changed during selection", "invalid"); } - const [runtimeObjects, archives, dataFiles] = await Promise.all([ + const [runtimeObjects, archives, dataFiles, headerFiles] = await Promise.all([ Promise.all(selectedVariants.map((artifact) => verifyArtifact(root, artifact))), Promise.all(selectedArchives.map((artifact) => verifyArtifact(root, artifact))), Promise.all(selectedData.map((artifact) => verifyArtifact(root, artifact))), + Promise.all(selectedHeaders.map((artifact) => verifyArtifact(root, artifact))), ]); await Promise.all(manifest.licenses.map((license) => readFile(join(root, license.path)))).catch( () => { @@ -248,11 +259,20 @@ export async function loadRuntimePack(options: { runtimeObjects, archives, dataFiles, + headerFiles, systemLibraries: selected.systemLibraries, - dependencyPaths: [packagePath, manifestPath, ...runtimeObjects, ...archives, ...dataFiles], + dependencyPaths: [ + packagePath, + manifestPath, + ...runtimeObjects, + ...archives, + ...dataFiles, + ...headerFiles, + ], sourceDependencies, selectedRuntimeArtifacts: selectedVariants, selectedArchiveArtifacts: selectedArchives, selectedDataArtifacts: selectedData, + selectedHeaderArtifacts: selectedHeaders, }; } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 4dc3f431e..311224a10 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5492,11 +5492,17 @@ function lowerPrimitiveProtoCall( const operand = lowerer.lowerExpr(recv); if (operand.type.kind !== "f64") return null; const stmts: IrStmt[] = []; + let formatted: IrExpr = operand; if (call.arguments.nodes.length === 1) { // An explicit undefined may be any expression that evaluates to it - // (a call, a variable), so it still runs — in argument order, before - // the formatting. Its unit value has no standalone representation, - // so it is coerced into the dynamic slot every argument occupies. + // (a call, a variable), so it still runs, after the receiver and + // before the formatting: the receiver's value is read first, so an + // argument that writes it does not change the number formatted. Its + // unit value has no standalone representation, so it is coerced + // into the dynamic slot every argument occupies. + const receiver = lowerer.declareHiddenLocal("%localeReceiver", F64); + stmts.push({ kind: "varDecl", localId: receiver.id, init: operand, loc }); + formatted = varRef(receiver.id, F64, loc); const arg = call.arguments.nodes[0]!; const value = lowerer.lowerExpr(arg); stmts.push({ @@ -5508,7 +5514,7 @@ function lowerPrimitiveProtoCall( const call_: IrExpr = { kind: "libCall", fn: "icu.numFormatDefault", - args: [operand], + args: [formatted], type: STRING, loc, }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0eaa550b9..1b5b85459 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -107,7 +107,11 @@ import { } from "./backend/targets.js"; import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js"; import { createNativeLinkInfo } from "./backend/native-link-info.js"; -import { RuntimePackError, loadRuntimePack } from "./backend/runtime-pack.js"; +import { + RuntimePackError, + loadRuntimePack, + type RuntimePackSelection, +} from "./backend/runtime-pack.js"; import { createNativeLinkPlan } from "./backend/link-plan.js"; import { nativeProfileKey, @@ -627,17 +631,42 @@ async function installRuntimeDataBesideExecutable( features: EarlyExecutableNativeFeatures, outPath: string, ): Promise { - if (features.intl !== true) return; + const pack = await intlRuntimePack(features); + if (pack !== null) await installRuntimeDataFiles(pack, outPath); +} + +/** The host target's runtime pack selection for an Intl program, or null + * when the program does not use Intl or the build has no host target. */ +async function intlRuntimePack( + features: EarlyExecutableNativeFeatures, +): Promise { + if (features.intl !== true) return null; const target = nativeCodegenTarget(); - if (target === null) return; - await installRuntimeDataFiles( - await loadRuntimePack({ - target, - features, - optimization: features.optimization ?? "release", - }), - outPath, + if (target === null) return null; + return loadRuntimePack({ + target, + features, + optimization: features.optimization ?? "release", + }); +} + +/** The pack's ICU archive and the include root of the ICU headers it ships + * (`/unicode/*.h`), both verified by loadRuntimePack, for the + * C-source path to link and compile scr_intl.c against. Null when the pack + * lists no headers: that path then builds ICU from its fetched release. */ +function runtimePackIcuInputs( + pack: RuntimePackSelection | null, +): { archive: string; include: string } | null { + if (pack === null) return null; + const header = pack.selectedHeaderArtifacts[0]; + const index = pack.selectedArchiveArtifacts.findIndex( + (artifact) => "id" in artifact && artifact.id === "icu", ); + if (header === undefined || index < 0) return null; + return { + archive: pack.archives[index]!, + include: join(pack.root, dirname(dirname(header.path))), + }; } async function compileExecutableNative( @@ -700,6 +729,8 @@ async function compileExecutableNative( throw new InternalCompilerError("program objects must link against a precompiled runtime pack"); if (staticLink) throw new InternalCompilerError("--static executables link against a precompiled runtime pack"); + const intlPack = await intlRuntimePack(features); + const icuPack = runtimePackIcuInputs(intlPack); await compileExternalC({ ...(llvmVisiblePath === undefined || llvmVisiblePath === llvmPath ? { cPath: llvmPath } @@ -731,6 +762,7 @@ async function compileExecutableNative( zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), ...(features.intl ? { intl: true } : {}), + ...(icuPack === null ? {} : { icuPack }), assert: features.assert, inspect: features.inspect, dynInvoke: features.dynInvoke, @@ -794,7 +826,7 @@ async function compileExecutableNative( frameworks: ffi.frameworks, }), }); - await installRuntimeDataBesideExecutable(features, outPath); + if (intlPack !== null) await installRuntimeDataFiles(intlPack, outPath); } interface NativeProgramObject { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..c3fc81f09 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -205,6 +205,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "num.toPrecision", "num.toExponentialDigits", "str.localeCase", + // The default-locale ICU formatters throw an Error when the ICU data file + // is missing (scr_intl.c). + "icu.numFormatDefault", + "icu.dateLocaleString", + "icu.dateLocaleTimeString", "num.toStringRadix", "num.toStringRadixF64", "insp.jsonDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ab382aaca..aba7c1101 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1001,7 +1001,8 @@ function buildLibFnSignatures() { * negative inputs rounding to zero. Result +1; never throws. */ "intl.numFormatEnUs": { argTypes: [F64], result: STRING }, /** `x.toLocaleString()` at the default locale: the same ICU number - * engine V8 uses, over the full ICU data (scr_intl.c). Result +1. */ + * engine V8 uses, over the full ICU data (scr_intl.c). Throws an Error + * when the ICU data file is missing. Result +1. */ "icu.numFormatDefault": { argTypes: [F64], result: STRING }, /** `process.config.variables.v8_enable_i18n_support`: the number 1, as * Node (which bundles ICU) answers. Reading it selects the `icu` @@ -1009,7 +1010,8 @@ function buildLibFnSignatures() { * carries it (scr_intl.c). */ "icu.i18nSupport": { argTypes: [], result: F64 }, /** `d.toLocaleString()` / `d.toLocaleTimeString()` at the default - * locale over `d`'s time value (scr_intl.c). Result +1. */ + * locale over `d`'s time value (scr_intl.c). Throws an Error when the + * ICU data file is missing. Result +1. */ "icu.dateLocaleString": { argTypes: [DATE_T], result: STRING }, "icu.dateLocaleTimeString": { argTypes: [DATE_T], result: STRING }, /** ES Symbol values (scr_symbol.c — link-gated by moduleUsesSymbol). diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index afea867b9..451407a3e 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; @@ -263,3 +263,93 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( } }, ); + +const linuxPackRoot = fileURLToPath(new URL("../../../runtime-linux-x64-gnu", import.meta.url)); + +test.skipIf( + process.platform !== "linux" || + process.arch !== "x64" || + !existsSync(join(linuxPackRoot, "runtime-pack.json")), +)("scr_intl.c compiles against the ICU headers the pack ships, and refuses without them", () => { + const target = NATIVE_TARGETS.find((item) => item.name === "linux-x64-gnu"); + if (target === undefined) throw new Error("unsupported sanitizer test host"); + const runtimeSourceRoot = fileURLToPath(new URL("../../../runtime", import.meta.url)); + const { version } = JSON.parse(readFileSync(join(runtimeSourceRoot, "package.json"), "utf8")) as { + version: string; + }; + const installed = JSON.parse( + readFileSync(join(linuxPackRoot, "runtime-pack.json"), "utf8"), + ) as RuntimePackManifest; + expect(installed.headers?.length ?? 0).toBeGreaterThan(0); + const directory = mkdtempSync("/tmp/scriptc-sanitized-intl-"); + const variant = { + path: "artifacts/dev/runtime/default/scr_intl.o", + sha256: "0".repeat(64), + size: 0, + id: "default", + when: {}, + defines: [], + }; + const base: RuntimePackManifest = { ...installed }; + delete base.headers; + const manifest = (headers: RuntimePackManifest["headers"]): RuntimePackManifest => ({ + ...base, + version, + flavors: { + dev: { + optimization: "-O0", + runtime_units: [{ source: "scr_intl.c", predicate: true, variants: [variant] }], + }, + }, + archives: [], + ...(headers === undefined ? {} : { headers }), + }); + const build = (packManifest: RuntimePackManifest, stage: string) => + buildSanitizedRuntime( + { + compilerVersion: version, + target, + helper: target.helper, + runtimeSourceRoot, + ts7Executable: "unused", + helperExecutable: "unused", + helperPackageRoot: "unused", + runtimePackRoot: "unused", + linker: "unused", + linkerArgs: [], + dsymutil: "unused", + }, + { + root: linuxPackRoot, + manifest: packManifest, + licenses: packManifest.licenses, + packageText: "", + manifestText: "", + flavor: "dev", + selected: { + runtime: [variant], + archives: [], + data: [], + systemLibraries: [], + features: { ...features, intl: true }, + }, + }, + stage, + "executable", + null, + ); + try { + const built = build(manifest(installed.headers), join(directory, "with-headers")); + expect(built.runtimeObjects).toEqual([join(directory, "with-headers", "scr_intl.o")]); + expect(statSync(built.runtimeObjects[0]!).size).toBeGreaterThan(0); + expect(() => build(manifest(undefined), join(directory, "without"))).toThrow(/no ICU headers/); + const changed = installed.headers!.map((header, index) => + index === 0 ? { ...header, sha256: "f".repeat(64) } : header, + ); + expect(() => build(manifest(changed), join(directory, "changed"))).toThrow( + /does not match its manifest/, + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/src/native/sanitizer.ts b/packages/compiler/src/native/sanitizer.ts index d00e2fbd7..d107ef81c 100644 --- a/packages/compiler/src/native/sanitizer.ts +++ b/packages/compiler/src/native/sanitizer.ts @@ -1,7 +1,7 @@ import type { NativeOptimization } from "../backend/optimization.js"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { NativeCodegenError } from "../backend/native-codegen-core.js"; import { requireNativeArtifact, runNativeTool } from "../backend/native-tools.js"; import { @@ -12,6 +12,7 @@ import { import { runtimePackFlavorKey, selectedRuntimeUnits, + type RuntimePackManifest, type RuntimePackMode, } from "../backend/runtime-pack-core.js"; import { LRE_SOURCES, zlibInputs } from "../backend/vendor-inputs.js"; @@ -63,6 +64,41 @@ function sourceFingerprint(root: string): string { return hash.digest("hex"); } +/** The include root of the ICU C API headers the pack ships for + * scr_intl.c (manifest `headers`, all under `/unicode/`), after each + * header matches its recorded size and sha256. @scriptc/runtime's sources + * carry no ICU headers, and a host's own ICU headers would bind another + * release's versioned symbols. */ +function icuIncludeRoot(selection: NativeRuntimeSelection, manifest: RuntimePackManifest): string { + const headers = manifest.headers ?? []; + if (headers.length === 0) + throw new NativeCodegenError( + "SC3003", + "the runtime pack has no ICU headers, which a sanitized Intl build compiles scr_intl.c against; reinstall the runtime package", + ); + const root = dirname(dirname(headers[0]!.path)); + for (const header of headers) { + let bytes: Buffer | null = null; + try { + bytes = readFileSync(join(selection.root, header.path)); + } catch { + bytes = null; + } + if ( + dirname(dirname(header.path)) !== root || + basename(dirname(header.path)) !== "unicode" || + bytes === null || + bytes.length !== header.size || + createHash("sha256").update(bytes).digest("hex") !== header.sha256 + ) + throw new NativeCodegenError( + "SC3003", + `runtime pack ICU header is missing or does not match its manifest: ${header.path}`, + ); + } + return join(selection.root, root); +} + /** The installed pack's matrix also selects sanitizer source units and * feature defines, keeping native and seed runtime reachability aligned. */ export function buildSanitizedRuntime( @@ -103,10 +139,13 @@ export function buildSanitizedRuntime( const zlib = join(vendor, "zlib"); const mbedtls = join(vendor, "mbedtls"); const brotli = join(vendor, "brotli", "c"); + const manifest = runtimePackManifest(selection); const cacheIdentity = contentDigest( JSON.stringify({ schema: 1, sourceIdentity, + // The ICU headers are pack artifacts, outside the runtime sources. + headers: (manifest.headers ?? []).map((header) => header.sha256), compilerIdentity, driver, target: toolchain.target, @@ -127,7 +166,6 @@ export function buildSanitizedRuntime( } return output; }; - const manifest = runtimePackManifest(selection); const key = runtimePackFlavorKey(manifest, selection.flavor, mode); const flavor = manifest.flavors[key]; if (flavor === undefined) throw new NativeCodegenError("SC3003", `runtime pack lacks ${key}`); @@ -151,12 +189,16 @@ export function buildSanitizedRuntime( ...(toolchain.target.platform === "linux" ? ["-ffunction-sections", "-fdata-sections"] : []), ]; // The units and variants the pack selection chose, compiled from source. - const runtimeObjects = selectedRuntimeUnits(flavor, selection.selected.features).map( - ({ source, variant }) => - compile(join(src, source), source.replace(/\.c$/, ".o"), [ - ...common, - ...variant.defines.map((define) => "-D" + define), - ]), + const units = selectedRuntimeUnits(flavor, selection.selected.features); + const icuInclude = units.some(({ source }) => source === "scr_intl.c") + ? ["-I", icuIncludeRoot(selection, manifest)] + : []; + const runtimeObjects = units.map(({ source, variant }) => + compile(join(src, source), source.replace(/\.c$/, ".o"), [ + ...common, + ...(source === "scr_intl.c" ? icuInclude : []), + ...variant.defines.map((define) => "-D" + define), + ]), ); const archives: string[] = []; for (const archive of selection.selected.archives) { @@ -170,7 +212,8 @@ export function buildSanitizedRuntime( // (~600 TUs), so instrumenting it would add a multi-minute C++ build to // the first Intl program in every sanitized lane. The pack's archive is // linked as shipped instead: this repository's ICU code (scr_intl.c) - // still compiles instrumented with every other runtime unit, and + // still compiles instrumented with every other runtime unit, against + // the ICU headers the pack ships beside the archive (icuIncludeRoot), and // AddressSanitizer's interceptors cover ICU's allocations. The pack is // installed beside the sources, not an evictable cache, so the path can // be linked directly. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-c-utf8-locale.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-c-utf8-locale.ts.json new file mode 100644 index 000000000..a619dc4d6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-c-utf8-locale.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-tolocalestring-c-utf8-locale.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-posix-locale.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-posix-locale.ts.json new file mode 100644 index 000000000..5caa97e70 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-tolocalestring-posix-locale.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-tolocalestring-posix-locale.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 197adc516..36ec1211e 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -38,7 +38,11 @@ export const RUNTIME_PACK_MATRIX = withLibraryRuntimeFlavors( executable_section_elimination: EXECUTABLE_SECTION_ELIMINATION, runtime_units: [ ...BASE_RUNTIME_SOURCES.map((source) => ({ source, predicate: true })), - ...OPTIONAL_RUNTIME_SOURCES.map(([source, predicate]) => ({ source, predicate })), + // The macOS pack ships no ICU, so it omits scr_intl.c, and the compiler + // refuses the ICU-answered Intl forms for this target. + ...OPTIONAL_RUNTIME_SOURCES.filter(([source]) => source !== "scr_intl.c").map( + ([source, predicate]) => ({ source, predicate }), + ), ].map((unit) => ({ ...unit, variants: variantsFor(unit.source) })), archives: [ { id: "quickjs", predicate: false }, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index c4f81d4a0..5ddb25ba5 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -4,7 +4,7 @@ import { createHash } from "node:crypto"; import { availableParallelism, tmpdir } from "node:os"; import { existsSync } from "node:fs"; import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; -import { dirname, join, relative, sep } from "node:path"; +import { basename, dirname, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { gunzipSync } from "node:zlib"; @@ -684,6 +684,45 @@ async function build() { size: (await stat(destination)).size, }); } + // A sanitized build (`--sanitize` in the native compiler) compiles the + // runtime units from @scriptc/runtime's sources, and scr_intl.c includes + // ICU's C API headers, which that package does not carry. The pack ships + // the ICU headers scr_intl.c includes, as the compiler's dependency scan + // lists them, under one include root beside the archive. + const headers = []; + if (requestedArchives.has("icu")) { + const icu = join(await icuSourceRoot(), "source"); + const icuHeaderDirs = [join(icu, "common", "unicode"), join(icu, "i18n", "unicode")]; + const scan = await run(compiler, [ + ...compilerArgs, + ...commonFlags, + "-I", + join(icu, "common"), + "-I", + join(icu, "i18n"), + "-MM", + join(runtimeSrc, "scr_intl.c"), + ]); + const included = scan.stdout + .replaceAll("\\\n", " ") + .split(/\s+/) + .filter((path) => icuHeaderDirs.includes(dirname(path))) + .sort((a, b) => basename(a).localeCompare(basename(b))); + if (included.length === 0) throw new Error("scr_intl.c includes no ICU header"); + const includeRoot = join(stagedOutputRoot, "vendor", "icu", "include", "unicode"); + await mkdir(includeRoot, { recursive: true }); + for (const source of included) { + const destination = join(includeRoot, basename(source)); + if (existsSync(destination)) + throw new Error(`two ICU headers are named ${basename(source)}`); + await copyFile(source, destination); + headers.push({ + path: artifactPath(destination), + sha256: await sha256(destination), + size: (await stat(destination)).size, + }); + } + } await assertArtifactsExcludeStrings( stagedOutputRoot, runtimeForbiddenStrings(config.forbiddenArtifactStrings), @@ -758,6 +797,7 @@ async function build() { predicate: archiveSpecs.get(entry.id).predicate, })), ...(data.length === 0 ? {} : { data }), + ...(headers.length === 0 ? {} : { headers }), system_libraries: matrix.system_libraries, licenses: licensed.map(([, path, license]) => ({ path, license })), }; diff --git a/packages/runtime-pack-common/scripts/verify.mjs b/packages/runtime-pack-common/scripts/verify.mjs index 026fd84db..699dbbba1 100644 --- a/packages/runtime-pack-common/scripts/verify.mjs +++ b/packages/runtime-pack-common/scripts/verify.mjs @@ -24,6 +24,7 @@ const artifacts = [ ), ...manifest.archives, ...(manifest.data ?? []), + ...(manifest.headers ?? []), ]; for (const artifact of artifacts) { const bytes = await readFile(join(root, artifact.path)); diff --git a/packages/runtime/src/scr_intl.c b/packages/runtime/src/scr_intl.c index 1659c97da..cbe160e88 100644 --- a/packages/runtime/src/scr_intl.c +++ b/packages/runtime/src/scr_intl.c @@ -55,6 +55,10 @@ static int scr_intl_data_state; /* 0 = not tried, 1 = ready, 2 = failed */ +/* The default locale every formatter and the default word segmenter use, + * resolved with the data (scr_intl_default_locale). */ +static char scr_intl_locale[ULOC_FULLNAME_CAPACITY]; + /** The running executable's directory, without a trailing separator. */ static bool scr_intl_exe_dir(char *out, size_t size) { #if defined(__linux__) @@ -91,6 +95,28 @@ static bool scr_intl_file_exists(const char *path) { return true; } +/** V8's default locale (Intl::DefaultLocale): ICU's default, which ICU + * derives from the host environment, as a BCP 47 tag. ICU answers + * "en_US_POSIX" for LC_ALL=C, C.UTF-8 and POSIX, and V8 replaces that + * locale (and "c") with "en-US", so the POSIX tailoring's ungrouped + * numbers never reach a formatter. Every other default round-trips through + * its language tag, as V8's formatters receive it. */ +static void scr_intl_default_locale(void) { + const char *name = uloc_getDefault(); + if (strcmp(name, "en_US_POSIX") == 0 || strcmp(name, "c") == 0) { + strcpy(scr_intl_locale, "en_US"); + return; + } + char tag[ULOC_FULLNAME_CAPACITY]; + UErrorCode status = U_ZERO_ERROR; + int32_t tagLen = uloc_toLanguageTag(name, tag, (int32_t)sizeof tag, false, &status); + if (U_SUCCESS(status) && tagLen > 0 && tagLen < (int32_t)sizeof tag) { + uloc_forLanguageTag(tag, scr_intl_locale, (int32_t)sizeof scr_intl_locale, NULL, &status); + if (U_SUCCESS(status) && status != U_STRING_NOT_TERMINATED_WARNING) return; + } + snprintf(scr_intl_locale, sizeof scr_intl_locale, "%s", name); +} + /** True once ICU can resolve data: the file beside the executable, else * whatever ICU's own search finds (ICU_DATA). Never throws; the callers * do, with the same message. */ @@ -112,6 +138,7 @@ static bool scr_intl_data(void) { UNumberFormat *probe = unum_open(UNUM_DECIMAL, NULL, 0, NULL, NULL, &status); if (U_FAILURE(status) || probe == NULL) return false; unum_close(probe); + scr_intl_default_locale(); scr_intl_data_state = 1; return true; } @@ -235,10 +262,8 @@ ScrStr *scr_intl_num_format_default(double x) { scr_intl_no_data(); return NULL; } - /* locale NULL: the default locale, which ICU resolves from the host - * environment exactly as V8's own ICU does. */ UNumberFormatter *formatter = - unumf_openForSkeletonAndLocale(skeleton, skeletonLen, NULL, &status); + unumf_openForSkeletonAndLocale(skeleton, skeletonLen, scr_intl_locale, &status); if (U_FAILURE(status) || formatter == NULL) { scr_intl_no_data(); return NULL; @@ -302,7 +327,7 @@ static ScrStr *scr_intl_format_date(double ms, const char *skeletonText) { scr_intl_no_data(); return NULL; } - UDateTimePatternGenerator *generator = udatpg_open(NULL, &status); + UDateTimePatternGenerator *generator = udatpg_open(scr_intl_locale, &status); if (U_FAILURE(status) || generator == NULL) { scr_intl_no_data(); return NULL; @@ -317,11 +342,24 @@ static ScrStr *scr_intl_format_date(double ms, const char *skeletonText) { } /* A NULL time zone is ICU's default zone, which is the host's (TZ, else * /etc/localtime) — the same zone V8's default formatter uses. */ - UDateFormat *format = udat_open(UDAT_PATTERN, UDAT_PATTERN, NULL, NULL, 0, pattern, patternLen, &status); + UDateFormat *format = + udat_open(UDAT_PATTERN, UDAT_PATTERN, scr_intl_locale, NULL, 0, pattern, patternLen, &status); if (U_FAILURE(status) || format == NULL) { scr_intl_no_data(); return NULL; } + /* ECMAScript time is proleptic Gregorian: V8 moves a Gregorian + * calendar's Julian cutover to the start of ECMAScript time, -2^53 ms + * (JSDateTimeFormat), so dates before October 1582 keep their Gregorian + * fields. A non-Gregorian calendar answers U_UNSUPPORTED_ERROR and keeps + * its own rules, as in V8. */ + UErrorCode calendarStatus = U_ZERO_ERROR; + UCalendar *calendar = ucal_clone(udat_getCalendar(format), &calendarStatus); + if (U_SUCCESS(calendarStatus) && calendar != NULL) { + ucal_setGregorianChange(calendar, -9007199254740992.0, &calendarStatus); + if (U_SUCCESS(calendarStatus)) udat_setCalendar(format, calendar); + ucal_close(calendar); + } UChar text[256]; int32_t len = udat_format(format, ms, text, (int32_t)(sizeof text / sizeof *text), NULL, &status); udat_close(format); @@ -393,8 +431,8 @@ static UBreakIterator *scr_icu_word_iterator(const ScrIcuWordSegments *segments) return NULL; } UErrorCode status = U_ZERO_ERROR; - UBreakIterator *iter = - ubrk_open(UBRK_WORD, segments->locale, segments->text, segments->units, &status); + const char *locale = segments->locale != NULL ? segments->locale : scr_intl_locale; + UBreakIterator *iter = ubrk_open(UBRK_WORD, locale, segments->text, segments->units, &status); if (U_FAILURE(status) || iter == NULL) { scr_intl_no_data(); return NULL; @@ -421,7 +459,10 @@ static ScrDyn *scr_icu_word_iter_pack(void *ptr) { ScrIcuWordSegments *segments = ptr; ScrDyn *result = scr_dyn_new_arr(); UBreakIterator *iter = scr_icu_word_iterator(segments); - if (iter == NULL) return NULL; + if (iter == NULL) { + scr_dyn_release(result); + return NULL; + } int32_t start = ubrk_first(iter); for (int32_t end = start < 0 ? -1 : ubrk_next(iter); start >= 0 && end >= 0; start = end, end = ubrk_next(iter)) { @@ -570,8 +611,8 @@ ScrDyn *scr_intl_segmenter_word_new(ScrStr *locale) { ScrIcuWordSegmenter *value = calloc(1, sizeof *value); if (value == NULL) scr_trap("scriptc: out of memory\n"); value->rc = 1; - /* The empty tag is the absent locale: ICU's NULL selects the default - * locale (uloc_getDefault()), which is what V8 negotiates too, while an + /* The empty tag is the absent locale: NULL selects the default locale + * (scr_intl_default_locale), which is what V8 negotiates too, while an * empty locale name would select root. */ if (locale != NULL && locale->len > 0) { size_t len = locale->len; diff --git a/packages/runtime/src/scr_intl.h b/packages/runtime/src/scr_intl.h index 61f4820d5..31f82263d 100644 --- a/packages/runtime/src/scr_intl.h +++ b/packages/runtime/src/scr_intl.h @@ -5,6 +5,8 @@ #include "scr_string.h" +typedef struct ScrDyn ScrDyn; /* full definition in scr_dyn.h (C11 repeat) */ + /* ── Intl over ICU4C (scr_intl.c) ───────────────────────────────────── * Compiled and linked — with the vendored ICU archive — only into * programs whose IR selects the `icu` runtime feature (the `icu.*` diff --git a/packages/runtime/vendor/README.md b/packages/runtime/vendor/README.md index faa4b144e..e032f1a53 100644 --- a/packages/runtime/vendor/README.md +++ b/packages/runtime/vendor/README.md @@ -102,3 +102,5 @@ Three build properties are worth knowing: - **The C++ standard library is compiled from Zig's bundled sources and merged into the archive.** The `libcxx/src` and `libcxxabi/src` units (all but `cxa_personality.cpp`, the one unit that needs exceptions) compile beside ICU's and are archived with them under `cxx-libcxx-`/`cxx-libcxxabi-` members, so the final link stays a plain C link that the pack's `-lm` satisfies. Symbol renaming stays ON (ICU's `_78` suffixes), which is what lets the zero-cost gate (`icuSymbols()` in scripts/zero-cost-compare.mjs) prove that a binary which does not use Intl links no ICU symbol at all. The runtime-pack archive is `artifacts/vendor/icu/libscriptc-icu.a`; the source-build path builds the same archive lazily on the first Intl compile into the per-user build cache's `vendor/icu-78.3---/` directory (ensureIcuArchive in packages/compiler/src/backend/vendor-archives.ts). Intl-free binaries compile and link none of it. + +Beside the archive, the pack ships the ICU headers scr_intl.c includes in `artifacts/vendor/icu/include/unicode/`, listed with their sizes and sha256 in the manifest's `headers`. The pack build selects them from the compiler's `-MM` scan of scr_intl.c. A sanitized build in the native compiler compiles scr_intl.c from this package's sources, which carry no ICU headers, so it adds that directory to the include path and refuses with SC3003 when the pack lists no headers or a header does not match its hash. diff --git a/tests/corpus/intl-date-tolocalestring-default.ts b/tests/corpus/intl-date-tolocalestring-default.ts index 3cca6e104..feaf2fa4e 100644 --- a/tests/corpus/intl-date-tolocalestring-default.ts +++ b/tests/corpus/intl-date-tolocalestring-default.ts @@ -28,3 +28,14 @@ console.log(invalid.toLocaleString(), "|", invalid.toLocaleTimeString()); // The results are ordinary strings. const label = `Zeit: ${new Date(1700000000000).toLocaleTimeString()}`; console.log(label, label.length, new Date(0).toLocaleString().length); + +// Dates before the Gregorian reform keep their proleptic Gregorian fields, as +// in ECMAScript time: 1582-10-04, the year 1000, the year 1, and the earliest +// time value. +const early = [Date.UTC(1582, 9, 4, 12), Date.UTC(1582, 9, 15), Date.UTC(1000, 0, 1), -8.64e15]; +const yearOne = new Date(0); +yearOne.setUTCFullYear(1, 0, 1); +for (const ms of [...early, yearOne.getTime()]) { + const d = new Date(ms); + console.log(d.toLocaleString(), "|", d.toLocaleTimeString()); +} diff --git a/tests/corpus/intl-number-tolocalestring-default.ts b/tests/corpus/intl-number-tolocalestring-default.ts index e63a9373a..4d00ade5d 100644 --- a/tests/corpus/intl-number-tolocalestring-default.ts +++ b/tests/corpus/intl-number-tolocalestring-default.ts @@ -20,7 +20,7 @@ console.log(label, label.length, (42).toLocaleString().padStart(8, ".")); const mapped = xs.map((x) => x.toLocaleString()); console.log(mapped.join("|")); -// An explicit undefined argument may carry effects and is evaluated first. +// An explicit undefined argument may carry effects and is evaluated. let order = ""; function undef(value: string): undefined { order += value; @@ -28,6 +28,15 @@ function undef(value: string): undefined { } console.log((1234.5).toLocaleString(undef("a")), order); +// The receiver is read before the argument runs: an argument that writes +// the receiver's variable does not change the number formatted. +let receiver = 1234.5; +function reassign(): undefined { + receiver = 99; + return undefined; +} +console.log(receiver.toLocaleString(reassign()), receiver.toLocaleString()); + // The capability probe Node answers from its own ICU build: an Intl program // links ICU, so the binary's answer is Node's (the number 1), and reading it // is what links ICU. diff --git a/tests/corpus/intl-tolocalestring-c-utf8-locale.ts b/tests/corpus/intl-tolocalestring-c-utf8-locale.ts new file mode 100644 index 000000000..c5cd80605 --- /dev/null +++ b/tests/corpus/intl-tolocalestring-c-utf8-locale.ts @@ -0,0 +1,14 @@ +// @requires-icu +// @env: TZ=UTC,LC_ALL=C.UTF-8 +// The default locale under LC_ALL=C.UTF-8, which ICU also maps to its POSIX +// locale (en_US_POSIX, no grouping separators). V8 replaces that locale with +// en-US, so Node prints grouped numbers and en-US date patterns, and so does +// scr_intl.c. +const xs = [0, 1000, 1234.5, 1234567.891, -9876543.21, 1e21, 0.5]; +console.log(xs.map((x) => x.toLocaleString()).join(" | ")); +const dates = [new Date(0), new Date(1700000000000), new Date(Date.UTC(2023, 10, 14, 13, 5, 7))]; +for (const d of dates) console.log(d.toLocaleString(), "|", d.toLocaleTimeString()); + +// The default word segmenter reads the same locale. +const words = new Intl.Segmenter(undefined, { granularity: "word" }).segment("It's 1,234.5 apples."); +for (const { segment, isWordLike } of words) console.log(JSON.stringify(segment), isWordLike); diff --git a/tests/corpus/intl-tolocalestring-posix-locale.ts b/tests/corpus/intl-tolocalestring-posix-locale.ts new file mode 100644 index 000000000..2f90f1135 --- /dev/null +++ b/tests/corpus/intl-tolocalestring-posix-locale.ts @@ -0,0 +1,14 @@ +// @requires-icu +// @env: TZ=UTC,LC_ALL=C +// The default locale under LC_ALL=C. ICU's default locale is then its POSIX +// locale (en_US_POSIX), whose number format has no grouping separators. V8 +// replaces that locale with en-US, so Node prints grouped numbers and en-US +// date patterns, and so does scr_intl.c. +const xs = [0, 1000, 1234.5, 1234567.891, -9876543.21, 1e21, 0.5]; +console.log(xs.map((x) => x.toLocaleString()).join(" | ")); +const dates = [new Date(0), new Date(1700000000000), new Date(Date.UTC(2023, 10, 14, 13, 5, 7))]; +for (const d of dates) console.log(d.toLocaleString(), "|", d.toLocaleTimeString()); + +// The default word segmenter reads the same locale. +const words = new Intl.Segmenter(undefined, { granularity: "word" }).segment("It's 1,234.5 apples."); +for (const { segment, isWordLike } of words) console.log(JSON.stringify(segment), isWordLike); diff --git a/tests/harness/corpus-costs/intl-tolocalestring-c-utf8-locale.ts.json b/tests/harness/corpus-costs/intl-tolocalestring-c-utf8-locale.ts.json new file mode 100644 index 000000000..0e72e0eb9 --- /dev/null +++ b/tests/harness/corpus-costs/intl-tolocalestring-c-utf8-locale.ts.json @@ -0,0 +1 @@ +{ "plain": 4883, "san": 8679 } diff --git a/tests/harness/corpus-costs/intl-tolocalestring-posix-locale.ts.json b/tests/harness/corpus-costs/intl-tolocalestring-posix-locale.ts.json new file mode 100644 index 000000000..3a98fb30d --- /dev/null +++ b/tests/harness/corpus-costs/intl-tolocalestring-posix-locale.ts.json @@ -0,0 +1 @@ +{ "plain": 5140, "san": 8704 } diff --git a/tests/harness/intl-missing-data.test.ts b/tests/harness/intl-missing-data.test.ts new file mode 100644 index 000000000..4db4d3318 --- /dev/null +++ b/tests/harness/intl-missing-data.test.ts @@ -0,0 +1,73 @@ +/* The default-locale ICU formatters (scr_intl.c) throw an Error when the + * ICU data file is missing. Node cannot reach this state (its data is inside + * the binary), so the test runs the compiled program without the data file + * beside it and with ICU_DATA pointing at an empty directory: each throw + * enters the surrounding catch, directly and through a closure, and the + * program continues after it. */ +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); + +const PROGRAM = ` +function attempt(label: string, run: () => string): void { + try { + console.log(label, run()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(label, "caught", error instanceof Error, message.includes("icudt78l.dat")); + } +} +attempt("number", () => (1234.5).toLocaleString()); +attempt("date", () => new Date(0).toLocaleString()); +attempt("time", () => new Date(0).toLocaleTimeString()); +try { + const text = (42).toLocaleString(); + console.log("direct", text); +} catch { + console.log("direct caught"); +} +console.log("after"); +`; + +test.skipIf(process.platform !== "linux" || process.arch !== "x64")( + "a missing ICU data file throws into the surrounding catch", + async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-intl-missing-data-")); + try { + const entry = join(dir, "main.ts"); + writeFileSync(entry, PROGRAM); + const result = await compile(entry, { + outPath: join(dir, "out", "main"), + outDir: join(dir, "out"), + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + rmSync(join(dirname(result.binaryPath), "icudt78l.dat"), { force: true }); + const empty = join(dir, "no-data"); + mkdirSync(empty); + const { stdout } = await execFileAsync(result.binaryPath, [], { + env: { ...process.env, ICU_DATA: empty }, + }); + expect(stdout).toBe( + [ + "number caught true true", + "date caught true true", + "time caught true true", + "direct caught", + "after", + "", + ].join("\n"), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + 300_000, +); diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 4a3d4c390..f86f4e194 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -1078,7 +1078,7 @@ describe.each(EMISSIONS)("K14: determinism fences, %s emission", (emission) => { test("a code-keyed teachings entry rides a non-fence refusal as the attributed note", async () => { const diags = await refusal( - `export function f(): number { return (1234.5).toLocaleString().length; }\n`, + `export function f(): number { return (1234.5).toLocaleString("de-DE").length; }\n`, { exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], determinism: { From 398e2e9e9cc92c18fc71393f5a140ce9a275242b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:42:47 -0500 Subject: [PATCH 043/193] Weigh program profile matches by the profile's counts The LLVM helper's emit reports the sums of the profile's counters on the functions it annotated and on the functions whose control flow changed since the profile was collected (annotated_counts and mismatched_counts), and the partition cache keeps them with each partition's match. The annotated ones are listed as the profile annotation leaves them, before block counts recover any. Builds of the native compiler print the share of the profile's counts that changed functions hold. --- native/llvm-codegen/src/emit.cpp | 143 +++++++++++++++--- .../compiler/src/backend/native-codegen.ts | 3 + .../src/backend/native-profile.test.ts | 14 ++ .../compiler/src/backend/native-profile.ts | 18 ++- packages/compiler/test/native-profile.test.ts | 29 +++- scripts/build-native-cli.mts | 9 ++ 6 files changed, 192 insertions(+), 24 deletions(-) diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index 17d69be72..ad02120fa 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -252,15 +252,20 @@ struct UnnameStructTypes : PassInfoMixin { // over partitions: functions that received its counts, functions whose // control flow changed since it was collected (hash mismatch), and functions // it has no record of (new since it was collected, or never run in training -// when the profile was merged with --sparse). A partition reused from the cache without its counts is counted -// in Unknown. Recovered counts the mismatched functions that --profile-blocks -// annotated, so it never exceeds Mismatched. +// when the profile was merged with --sparse). A partition reused from the cache +// without its counts is counted in Unknown. Recovered counts the mismatched +// functions that --profile-blocks annotated, so it never exceeds Mismatched. +// The two weights sum the profile's counters for the annotated and for the +// mismatched functions, so a changed hot function weighs more than a changed +// cold one. struct ProfileMatch { std::atomic Annotated{0}; std::atomic Mismatched{0}; std::atomic Missing{0}; std::atomic Recovered{0}; std::atomic Unknown{0}; + std::atomic AnnotatedCounts{0}; + std::atomic MismatchedCounts{0}; // Set once the annotation before inlining has run. The context-sensitive // annotation after inlining, which a single-module pipeline also runs, // reports its mismatched and missing functions again; they are not @@ -272,27 +277,32 @@ struct ProfileMatch { Mismatched += Other.Mismatched; Missing += Other.Missing; Recovered += Other.Recovered; + AnnotatedCounts += Other.AnnotatedCounts; + MismatchedCounts += Other.MismatchedCounts; } std::string serialize() const { - return formatv("{0} {1} {2} {3}", Annotated.load(), Mismatched.load(), - Missing.load(), Recovered.load()) + return formatv("{0} {1} {2} {3} {4} {5}", Annotated.load(), + Mismatched.load(), Missing.load(), Recovered.load(), + AnnotatedCounts.load(), MismatchedCounts.load()) .str(); } bool deserialize(StringRef Text) { - SmallVector Parts; + SmallVector Parts; Text.split(Parts, ' '); - uint64_t Values[4]; - if (Parts.size() != 4) + uint64_t Values[6]; + if (Parts.size() != 6) return false; - for (size_t I = 0; I < 4; ++I) + for (size_t I = 0; I < 6; ++I) if (Parts[I].getAsInteger(10, Values[I])) return false; Annotated += Values[0]; Mismatched += Values[1]; Missing += Values[2]; Recovered += Values[3]; + AnnotatedCounts += Values[4]; + MismatchedCounts += Values[5]; return true; } }; @@ -412,11 +422,77 @@ void reportProfileMatch(const ProfileMatch &Match) { {"mismatched", Match.Mismatched.load()}, {"missing", Match.Missing.load()}, {"recovered", Match.Recovered.load()}, - {"unknown_partitions", Match.Unknown.load()}}; + {"unknown_partitions", Match.Unknown.load()}, + {"annotated_counts", Match.AnnotatedCounts.load()}, + {"mismatched_counts", Match.MismatchedCounts.load()}}; json::Object Response{{"ok", true}, {"profile", std::move(Profile)}}; outs() << formatv("{0}\n", json::Value(std::move(Response))); } +// The sum of each function's counters in the indexed profile at `Path`, by +// profile name, read once per process. An unreadable profile has no counts +// (the annotation reports it). +const StringMap &profileCounts(const std::string &Path) { + static std::once_flag Once; + static StringMap Counts; + std::call_once(Once, [&Path] { + Expected> Reader = + IndexedInstrProfReader::create(Path, *vfs::getRealFileSystem()); + if (!Reader) { + consumeError(Reader.takeError()); + return; + } + for (const NamedInstrProfRecord &Record : **Reader) { + uint64_t Sum = 0; + for (uint64_t Count : Record.Counts) + Sum += Count; + Counts[Record.Name] += Sum; + } + consumeError((*Reader)->getError()); + }); + return Counts; +} + +// Weighs one module's profile match by the profile's counters: collects the +// annotated and the mismatched functions while the module is optimized, +// then adds their counts to the match. +class MatchWeigher { +public: + explicit MatchWeigher(const Module &M) { + for (const Function &F : M) + if (!F.isDeclaration()) + ProfileNames[F.getName()] = getIRPGOFuncName(F); + } + + void weigh(const std::string &ProfileUse, ProfileMatch &Match) const { + const StringMap &Counts = profileCounts(ProfileUse); + auto CountsOf = [&Counts](StringRef Name) -> uint64_t { + auto It = Counts.find(Name); + return It == Counts.end() ? 0 : It->second; + }; + // A context-sensitive profile annotates the module twice; each function + // counts once. + StringSet<> Seen; + for (const std::string &Name : Annotated) + if (Seen.insert(Name).second) + Match.AnnotatedCounts += CountsOf(Name); + // The mismatch warnings name functions by symbol. + for (const std::string &Name : Mismatched) { + auto It = ProfileNames.find(Name); + StringRef ProfileName = + It == ProfileNames.end() ? StringRef(Name) : StringRef(It->second); + if (Seen.insert(ProfileName).second) + Match.MismatchedCounts += CountsOf(ProfileName); + } + } + + std::vector Annotated; + std::vector Mismatched; + +private: + StringMap ProfileNames; +}; + // Reads the profile's header once, before any partition needs it: it must // hold IR-level counts. Context-sensitive counts lay out the code by them; // a profile that has them cannot take a second set. @@ -515,13 +591,15 @@ struct Pipeline { }); } - // Adds to Names the profile names of the functions the annotation gave - // counts to, as soon as that pass has run. + // Adds to Names the profile names of the functions the annotation before + // inlining gave counts to, as soon as that pass has run. void collectAnnotated(std::vector &Names) { Callbacks.registerAfterPassCallback( - [&Names](StringRef PassID, Any IR, const PreservedAnalyses &) { - if (PassID != "PGOInstrumentationUse") + [&Names, Done = false](StringRef PassID, Any IR, + const PreservedAnalyses &) mutable { + if (Done || PassID != "PGOInstrumentationUse") return; + Done = true; if (const auto *M = llvm::any_cast(&IR)) for (const Function &F : **M) { std::optional Entry = F.getEntryCount(); @@ -714,13 +792,21 @@ emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level, StringRef OutputPath, std::vector> &Temporaries, ProfileMatch &Match) { bool Annotating = Profile && Profile->Action == PGOOptions::IRUse; + std::string ProfileUse = Annotating ? Profile->ProfileFile : ""; + MatchWeigher Weigher(M); if (Annotating) M.getContext().setDiagnosticHandler( - std::make_unique(Match)); + std::make_unique(Match, &Weigher.Mismatched)); Pipeline P(Machine, Level, std::move(Profile)); - if (Annotating) + if (Annotating) { + // Registered first, so it lists the functions the profile itself + // annotated, before block counts recover any. + P.collectAnnotated(Weigher.Annotated); P.afterProfileAnnotation(Match); + } P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); + if (Annotating) + Weigher.weigh(ProfileUse, Match); if (std::optional Failure = verify(M, "post_optimization_verification_failed")) return Failure; @@ -911,14 +997,16 @@ compileOptimizedPartitions(const EmitOptions &Options, return Failures.set({"target_machine_failed", LookupError}); LLVMContext Context; ProfileMatch PartitionMatch; + std::vector MismatchedNames; if (Annotating) - Context.setDiagnosticHandler( - std::make_unique(PartitionMatch)); + Context.setDiagnosticHandler(std::make_unique( + PartitionMatch, &MismatchedNames)); Expected> Loaded = loadPartition(Options, Plan.Modules[I], I, Context, *Machine); if (!Loaded) return Failures.set({"partition_failed", toString(Loaded.takeError())}); Module &Mod = **Loaded; + MatchWeigher Weigher(Mod); if (!Options.ImportBitcode.empty()) { // See emit(): the attribute is inert without a sanitizer pass and // would block inlining of the imported runtime bodies. @@ -937,8 +1025,12 @@ compileOptimizedPartitions(const EmitOptions &Options, // Profile instrumentation or annotation happens here, before the thin // link; the post-link optimization reads the annotated weights. Pipeline P(*Machine, Level, profileOptions(Options)); - if (Annotating) + if (Annotating) { + // Registered first, so it lists the functions the profile itself + // annotated, before block counts recover any. + P.collectAnnotated(Weigher.Annotated); P.afterProfileAnnotation(PartitionMatch); + } ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level); MPM.addPass(UnnameStructTypes()); // The module hash is set at the thin link instead (see Names). @@ -948,6 +1040,8 @@ compileOptimizedPartitions(const EmitOptions &Options, MPM.run(Mod, P.MAM); } if (Annotating) { + Weigher.Mismatched = std::move(MismatchedNames); + Weigher.weigh(Options.ProfileUse, PartitionMatch); Match.add(PartitionMatch); // Stored before the summary, so a reused summary finds its counts. Cache.store(Key, "match", PartitionMatch.serialize()); @@ -1445,15 +1539,22 @@ std::optional emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine, OptimizationLevel Level, ProfileMatch &Match) { bool Annotating = !Options.ProfileUse.empty(); + MatchWeigher Weigher(M); if (Annotating) M.getContext().setDiagnosticHandler( - std::make_unique(Match)); + std::make_unique(Match, &Weigher.Mismatched)); { Pipeline P(Machine, Level, profileOptions(Options)); - if (Annotating) + if (Annotating) { + // Registered first, so it lists the functions the profile itself + // annotated, before block counts recover any. + P.collectAnnotated(Weigher.Annotated); P.afterProfileAnnotation(Match); + } P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); } + if (Annotating) + Weigher.weigh(Options.ProfileUse, Match); if (std::optional Failure = verify(M, "post_optimization_verification_failed")) return Failure; diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index c683ffaee..c7c18a8a7 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -563,6 +563,9 @@ function helperProfileMatch(match: NativeProfileMatch): Record { missing: match.missing, recovered: match.recovered, unknown_partitions: match.unknownPartitions, + ...(match.annotatedCounts === undefined || match.mismatchedCounts === undefined + ? {} + : { annotated_counts: match.annotatedCounts, mismatched_counts: match.mismatchedCounts }), }; } diff --git a/packages/compiler/src/backend/native-profile.test.ts b/packages/compiler/src/backend/native-profile.test.ts index 7d2119d4b..77207f834 100644 --- a/packages/compiler/src/backend/native-profile.test.ts +++ b/packages/compiler/src/backend/native-profile.test.ts @@ -145,6 +145,20 @@ test("the helper's profile match is read from its output", () => { '{"ok":true,"profile":{"annotated":1,"mismatched":0,"missing":0,"recovered":-1,"unknown_partitions":0}}\n', ), ).toBeNull(); + // With the profile's counters on the annotated and the mismatched functions. + expect( + parseNativeProfileMatch( + '{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"recovered":0,"unknown_partitions":0,"annotated_counts":9000,"mismatched_counts":1000}}\n', + ), + ).toEqual({ + annotated: 120, + mismatched: 7, + missing: 40, + recovered: 0, + unknownPartitions: 0, + annotatedCounts: 9000, + mismatchedCounts: 1000, + }); // A helper that predates the report prints nothing. expect(parseNativeProfileMatch("")).toBeNull(); expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull(); diff --git a/packages/compiler/src/backend/native-profile.ts b/packages/compiler/src/backend/native-profile.ts index fa6fc134f..b581c73ed 100644 --- a/packages/compiler/src/backend/native-profile.ts +++ b/packages/compiler/src/backend/native-profile.ts @@ -111,13 +111,16 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine * profile has no record of (new, or never run while it was collected), * mismatched functions that the block-count file annotated (never more * than the mismatched ones), and partitions reused from the cache without - * their counts. */ + * their counts. Helpers that weigh the match also report the profile's + * counters on the annotated and on the mismatched functions. */ export interface NativeProfileMatch { annotated: number; mismatched: number; missing: number; recovered: number; unknownPartitions: number; + annotatedCounts?: number; + mismatchedCounts?: number; } /** The profile match in a successful helper emit's output, or null when the @@ -150,7 +153,18 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu unknownPartitions === null ) continue; - return { annotated, mismatched, missing, recovered, unknownPartitions }; + const annotatedCounts = count("annotated_counts"); + const mismatchedCounts = count("mismatched_counts"); + return { + annotated, + mismatched, + missing, + recovered, + unknownPartitions, + ...(annotatedCounts === null || mismatchedCounts === null + ? {} + : { annotatedCounts, mismatchedCounts }), + }; } return null; } diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts index e860d4010..8fbecb1ee 100644 --- a/packages/compiler/test/native-profile.test.ts +++ b/packages/compiler/test/native-profile.test.ts @@ -426,6 +426,27 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { expect(await run(await link(dir, objects, "edited"), 2)).toBe(expectedExit(2, 7)); }); + test("the match report weighs the functions that changed by their counts", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-weights-")); + dirs.push(dir); + const { profile } = await collect(dir, program(), 1, "train"); + // f4 gained a branch, so the profile no longer describes it. + const edited = program(0, true); + const { outputs, stdout } = await emitReporting(dir, edited, { + outputs: 1, + name: "edited", + use: profile, + }); + expect(await run(await link(dir, outputs, "edited"), 2)).toBe(expectedExit(2)); + const { profile: match } = JSON.parse(stdout.trim().split("\n").at(-1)!) as { + profile: Record; + }; + expect(match["mismatched"]).toBe(1); + // main and the other f keep their counts; f4's are reported apart. + expect(match["mismatched_counts"]).toBeGreaterThan(0); + expect(match["annotated_counts"]).toBeGreaterThan(match["mismatched_counts"]!); + }); + test("the match report counts annotated functions that inlining removes", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-match-")); dirs.push(dir); @@ -438,7 +459,11 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { expect(await run(await link(dir, outputs, "guided"), 2)).toBe(inlinedExit(2)); // Every g and main received counts, though optimization then // inlined the helpers into main and deleted them. - expect(JSON.parse(stdout.trim().split("\n").at(-1)!)).toEqual({ + const report = JSON.parse(stdout.trim().split("\n").at(-1)!) as { + ok: boolean; + profile: Record; + }; + expect(report).toMatchObject({ ok: true, profile: { annotated: INLINED + 1, @@ -446,8 +471,10 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { missing: 0, recovered: 0, unknown_partitions: 0, + mismatched_counts: 0, }, }); + expect(report.profile["annotated_counts"]).toBeGreaterThan(0); const symbols = await execFileAsync("nm", [outputs[0]!]); expect(symbols.stdout).not.toMatch(/\bg\d\b/); }); diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index 6c17b829b..43eb2b27a 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -189,8 +189,17 @@ const match = useProfile && "artifact" in result ? result.artifact.profileMatch if (match !== undefined) { const profiled = match.annotated + match.mismatched; const share = profiled === 0 ? 0 : match.annotated / profiled; + // The same by the profile's counters: a changed hot function loses more + // of the profile's gain than a changed cold one. + const counts = + match.annotatedCounts === undefined || match.mismatchedCounts === undefined + ? 0 + : match.annotatedCounts + match.mismatchedCounts; console.log( `native compiler profile: ${match.annotated} of ${profiled} profiled functions match (${(100 * share).toFixed(1)}%); ${match.missing} functions have no profile` + + (counts > 0 + ? `; changed functions hold ${((100 * match.mismatchedCounts!) / counts).toFixed(1)}% of its counts` + : "") + (match.recovered > 0 ? `; ${match.recovered} functions annotated from the profile's block counts` : "") + From bfc32449bd012796454a7768702452ef79a74c02 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:49:31 -0500 Subject: [PATCH 044/193] Check section names only for ELF objects in the never-ran profile test Mach-O and COFF objects keep every function in one text section, so the test compares the objects built with and without the block counts on every target and checks the hot and unlikely sections on ELF targets. --- packages/compiler/test/native-profile.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts index 6744a0973..7af252657 100644 --- a/packages/compiler/test/native-profile.test.ts +++ b/packages/compiler/test/native-profile.test.ts @@ -190,6 +190,9 @@ function rareExit(argc: number): number { return v & 127; } +/** The target writes ELF objects. */ +const elfTarget = target !== null && !/apple|darwin|windows|mingw/.test(target.llvmTriple); + /** The section that holds `name` in an object file. */ async function sectionOf(object: string, name: string): Promise { const { stdout } = await execFileAsync("objdump", ["-t", object]); @@ -658,16 +661,27 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { // Without the block counts rare keeps no counts; with them it is cold // code. added, which the block counts do not name, keeps no counts. const [plain] = await emit(dir, rareProgram(), { outputs: 1, name: "plain", use: profile }); + const [marked] = await emit(dir, rareProgram(), { + outputs: 1, + name: "marked", + use: profile, + blocks, + }); + expect(await readFile(marked!)).not.toEqual(await readFile(plain!)); const [cold] = await emit(dir, rareProgram(true), { outputs: 1, name: "cold", use: profile, blocks, }); - expect(await sectionOf(plain!, "rare")).toBe(".text"); - expect(await sectionOf(cold!, "rare")).toMatch(/^\.text\.unlikely/); - expect(await sectionOf(cold!, "added")).toBe(".text"); - expect(await sectionOf(cold!, "hot")).toMatch(/^\.text\.hot/); + // ELF objects name the sections of hot and unlikely code; Mach-O and + // COFF objects keep every function in one text section. + if (elfTarget) { + expect(await sectionOf(plain!, "rare")).toBe(".text"); + expect(await sectionOf(cold!, "rare")).toMatch(/^\.text\.unlikely/); + expect(await sectionOf(cold!, "added")).toBe(".text"); + expect(await sectionOf(cold!, "hot")).toMatch(/^\.text\.hot/); + } expect(await run(await link(dir, [cold!], "cold"), 2)).toBe(rareExit(2)); }); From b7bcbc68d8d8662cb9b118664d79955be238527a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 02:58:50 -0500 Subject: [PATCH 045/193] Pass a Buffer to a patched stdio write from an encoded data listener A byte write to a replaced process.stdout.write or process.stderr.write was boxed with the 'data' chunk helper, which answers a string while a listener of a socket or HTTP response with setEncoding("utf8") runs. A `process.stdout.write(buffer)` from such a listener reached the replacement as a string. Node passes the Buffer it was given. The stdio hook now boxes a byte write as a Buffer whatever listener is running. --- .../main.ts.json | 7 ++++ packages/runtime/src/scr_events.c | 5 ++- .../main.ts | 32 +++++++++++++++++++ .../patch.mjs | 8 +++++ .../main.ts.json | 1 + 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts.json create mode 100644 tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts create mode 100644 tests/corpus/stdout-patch-buffer-from-encoded-data/patch.mjs create mode 100644 tests/harness/corpus-costs/stdout-patch-buffer-from-encoded-data/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts.json new file mode 100644 index 000000000..c26adc078 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/stdout-patch-buffer-from-encoded-data/patch.mjs", + "/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index de1d12105..9b918061d 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1534,9 +1534,12 @@ static bool scr_stdio_intercept(int fd, const void *data, size_t length, int fla } ScrDyn *chunk; if (flags & SCR_STDIO_HOOK_BYTES) { + /* Always a Buffer: a write from a 'data' listener of a stream with an + * encoding set is still the bytes it was given. */ ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)length); memcpy(b->data, data, length); - chunk = scr_dyn_new_chunk(b); + b->is_buffer = true; + chunk = scr_dyn_new_bytes(b); scr_bytes_release(b); } else { ScrStr *text = scr_str_new(data, length); diff --git a/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts b/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts new file mode 100644 index 000000000..95e626f36 --- /dev/null +++ b/tests/corpus/stdout-patch-buffer-from-encoded-data/main.ts @@ -0,0 +1,32 @@ +// A Buffer written to a replaced process.stdout.write from a 'data' listener +// of a socket or an HTTP response with setEncoding("utf8") arrives as a +// Buffer; the listener's own chunks are strings. patch.mjs installs the +// replacement and prints what it received at exit. +import * as http from "node:http"; +import * as net from "node:net"; +import "./patch.mjs"; + +const tcp = net.createServer((socket) => socket.end("tcp-data")); +tcp.listen(0, "127.0.0.1", () => { + const port = (tcp.address() as net.AddressInfo).port; + const client = net.connect(port, "127.0.0.1"); + client.setEncoding("utf8"); + client.on("data", (text: string) => { + process.stdout.write(Buffer.from(`socket ${typeof text} ${text}`)); + process.stdout.write(`socket text ${text}`); + }); + client.on("end", () => { + tcp.close(); + const server = http.createServer((req, res) => res.end("http-data")); + server.listen(0, "127.0.0.1", () => { + const httpPort = (server.address() as net.AddressInfo).port; + http.get(`http://127.0.0.1:${httpPort}/`, (res) => { + res.setEncoding("utf8"); + res.on("data", (text: string) => { + process.stdout.write(Buffer.from(`response ${typeof text} ${text}`)); + }); + res.on("end", () => server.close()); + }); + }); + }); +}); diff --git a/tests/corpus/stdout-patch-buffer-from-encoded-data/patch.mjs b/tests/corpus/stdout-patch-buffer-from-encoded-data/patch.mjs new file mode 100644 index 000000000..2432a176b --- /dev/null +++ b/tests/corpus/stdout-patch-buffer-from-encoded-data/patch.mjs @@ -0,0 +1,8 @@ +// Replaces process.stdout.write with a recorder; an exit handler prints what it saw. +const seen = []; +process.stdout.write = function (chunk) { + const kind = typeof chunk === "string" ? "string" : Buffer.isBuffer(chunk) ? "Buffer" : typeof chunk; + seen.push(`${kind}:${String(chunk)}`); + return true; +}; +process.on("exit", () => process.stderr.write(`${seen.join("\n")}\n`)); diff --git a/tests/harness/corpus-costs/stdout-patch-buffer-from-encoded-data/main.ts.json b/tests/harness/corpus-costs/stdout-patch-buffer-from-encoded-data/main.ts.json new file mode 100644 index 000000000..9e947229f --- /dev/null +++ b/tests/harness/corpus-costs/stdout-patch-buffer-from-encoded-data/main.ts.json @@ -0,0 +1 @@ +{ "plain": 5044, "san": 13845 } From b319186a684959a5b9d63906d9c5a11fe48fd1fe Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:01:39 -0500 Subject: [PATCH 046/193] Count the arguments a replaced stdio write receives A JavaScript function assigned to process.stdout.write or process.stderr.write takes its parameters from the call, not from Node's overloads, but the lowering returned before giving the function its arguments slot. `arguments.length` then folded to the declared parameter count (3 for `function (chunk, encoding, callback)` called with one argument, 0 for a function without parameters), and reading `arguments[i]` was refused at run time. The replacement now receives the call's arguments, so `arguments` holds what the caller passed, as in Node. --- .../src/frontend/lowering/call-signatures.ts | 3 ++ ...stdio-write-replacement-arguments.cjs.json | 6 +++ .../stdio-write-replacement-arguments.cjs | 43 +++++++++++++++++++ ...stdio-write-replacement-arguments.cjs.json | 1 + 4 files changed, 53 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdio-write-replacement-arguments.cjs.json create mode 100644 tests/corpus/stdio-write-replacement-arguments.cjs create mode 100644 tests/harness/corpus-costs/stdio-write-replacement-arguments.cjs.json diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 45329d077..783082288 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -1062,6 +1062,9 @@ export function lambdaSignature( type: DYN, mode: "required", })); + // `arguments` holds the call's own tuple: a console line passes one + // or two arguments whatever the replacement declares. + appendArgumentsParameter(lowerer, node, shapes, "arguments"); return { shapes, funcType: funcTypeFromParamShapes(shapes, DYN) }; } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdio-write-replacement-arguments.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdio-write-replacement-arguments.cjs.json new file mode 100644 index 000000000..23315d78e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdio-write-replacement-arguments.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stdio-write-replacement-arguments.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/stdio-write-replacement-arguments.cjs b/tests/corpus/stdio-write-replacement-arguments.cjs new file mode 100644 index 000000000..c33a78210 --- /dev/null +++ b/tests/corpus/stdio-write-replacement-arguments.cjs @@ -0,0 +1,43 @@ +// A replaced process.stdout.write or process.stderr.write sees the call's own +// arguments: arguments.length is the number the caller passed, whatever the +// replacement declares. +const seen = []; +const originalOut = process.stdout.write; +process.stdout.write = function (chunk, encodingOrCallback, callback) { + seen.push(`stdout declared3 ${arguments.length} ${typeof callback}`); + return true; +}; +process.stdout.write("one"); +process.stdout.write("two", "utf8"); +process.stdout.write("three", "utf8", () => {}); +process.stdout.write = function () { + seen.push(`stdout declared0 ${arguments.length} ${arguments[0]}`); + return true; +}; +process.stdout.write("zero"); +process.stdout.write = function (chunk) { + seen.push(`stdout declared1 ${arguments.length} ${arguments[1]}`); + return true; +}; +process.stdout.write("a", "latin1"); +process.stdout.write = originalOut; +const originalErr = process.stderr.write; +process.stderr.write = function (chunk, encodingOrCallback, callback) { + seen.push(`stderr declared3 ${arguments.length} ${typeof callback}`); + return true; +}; +process.stderr.write("one"); +process.stderr.write("two", "utf8"); +process.stderr.write("three", "utf8", () => {}); +process.stderr.write = function () { + seen.push(`stderr declared0 ${arguments.length} ${arguments[0]}`); + return true; +}; +process.stderr.write("zero"); +process.stderr.write = function (chunk) { + seen.push(`stderr declared1 ${arguments.length} ${arguments[1]}`); + return true; +}; +process.stderr.write("a", "latin1"); +process.stderr.write = originalErr; +console.log(seen.join("\n")); diff --git a/tests/harness/corpus-costs/stdio-write-replacement-arguments.cjs.json b/tests/harness/corpus-costs/stdio-write-replacement-arguments.cjs.json new file mode 100644 index 000000000..2b44f308c --- /dev/null +++ b/tests/harness/corpus-costs/stdio-write-replacement-arguments.cjs.json @@ -0,0 +1 @@ +{ "plain": 3757, "san": 5000 } From 7921e020c2808b090b149fe5f17f7af74be7b620 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:13:45 -0500 Subject: [PATCH 047/193] Deliver a line to a single line listener without the listener snapshot --- .../readline-line-listener-changes.ts.json | 6 ++++ packages/runtime/src/scr_readline.c | 11 +++++++ .../corpus/readline-line-listener-changes.ts | 32 +++++++++++++++++++ .../readline-line-listener-changes.ts.json | 1 + 4 files changed, 50 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-line-listener-changes.ts.json create mode 100644 tests/corpus/readline-line-listener-changes.ts create mode 100644 tests/harness/corpus-costs/readline-line-listener-changes.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-line-listener-changes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-line-listener-changes.ts.json new file mode 100644 index 000000000..f00b31e83 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-line-listener-changes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/readline-line-listener-changes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_readline.c b/packages/runtime/src/scr_readline.c index 8d0108649..94becf5d7 100644 --- a/packages/runtime/src/scr_readline.c +++ b/packages/runtime/src/scr_readline.c @@ -105,6 +105,17 @@ void scr_rl_emit(ScrRl *rl, int ev, ScrStr *line, ScrError *err) { RlList *l = &rl->ev[ev]; size_t n = l->n; if (n == 0) return; + /* One persistent line listener (the usual 'line' handler): no snapshot + * and no once-listener pass. The retain keeps the closure alive if the + * listener removes itself. */ + if (n == 1 && ev == RL_EV_LINE && !l->v[0].once && !l->v[0].iter) { + RlEntry e = l->v[0]; + if (scr_exc_pending()) return; + scr_closure_retain(e.cb); + ((ScrRlLineFn)e.fn)(e.cb, scr_str_retain(line)); + scr_closure_release(e.cb); + return; + } /* Most events have a listener or two: their snapshot stays on the stack. */ RlEntry small[4]; RlEntry *snap = n <= 4 ? small : malloc(n * sizeof *snap); diff --git a/tests/corpus/readline-line-listener-changes.ts b/tests/corpus/readline-line-listener-changes.ts new file mode 100644 index 000000000..260a64774 --- /dev/null +++ b/tests/corpus/readline-line-listener-changes.ts @@ -0,0 +1,32 @@ +// 'line' listeners that change while a line is delivered: a listener added +// by the only line listener starts with the next line, after which both +// run in order; a once listener next to a persistent one runs for one line; +// and no listener sees a line twice. +import { createInterface } from "node:readline"; +import { Readable } from "node:stream"; + +const text = "a\nb\nc\nd\ne\nf\n"; + +const first = createInterface({ input: Readable.from([text]) }); +let seen = 0; +const counting = (line: string): void => { + seen++; + console.log("counting", line); + if (seen === 2) first.on("line", (later: string) => console.log("later", later)); +}; +first.on("line", counting); +first.on("close", () => { + console.log("first closed", seen); + + const second = createInterface({ input: Readable.from([text]) }); + second.once("line", (line: string) => console.log("once", line)); + second.on("line", (line: string) => console.log("every", line)); + second.on("close", () => { + console.log("second closed"); + + const third = createInterface({ input: Readable.from(["x\ny\n", "z\n"]) }); + const seenLines: string[] = []; + third.on("line", (line: string) => seenLines.push(line)); + third.on("close", () => console.log("third", seenLines.join(","))); + }); +}); diff --git a/tests/harness/corpus-costs/readline-line-listener-changes.ts.json b/tests/harness/corpus-costs/readline-line-listener-changes.ts.json new file mode 100644 index 000000000..c5cd06a1f --- /dev/null +++ b/tests/harness/corpus-costs/readline-line-listener-changes.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 7400 } From 37128308c39b19590ccd93f097b1258c0f4b0915 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:22:35 -0500 Subject: [PATCH 048/193] Match Node's emitWarning, kill, chdir and userInfo behavior process.emitWarning checks its arguments in Node's order: an options object replaces type with `options.type || "Warning"` and code with `options.code`, type and code are checked before the warning and for Error warnings too, an empty type names the warning "Warning", and a DeprecationWarning type returns at once under process.noDeprecation. Wrong kinds throw ERR_INVALID_ARG_TYPE with Node's "Received ..." tail. An instance of an Error subclass is an Error warning, and a typed record in the options position is read as the options object. The default report reads the warning's message, code and detail as property reads do, so an Error subclass's own code is reported. A deprecation thrown under process.throwDeprecation is thrown as the Error it encodes, so the uncaught report names it. process.kill throws ERR_UNKNOWN_SIGNAL for an unknown signal name, as subprocess.kill does, and a failed kill(2) is Node's errnoException with errno, code and syscall. A failed process.chdir names the working directory as path and the target as dest. os.userInfo() reads the passwd entry of the effective uid: uid and gid are the entry's pw_uid and pw_gid (-1 on Windows), not the process ids. os.homedir() answers $HOME whenever it is set, an empty value included. The minimal test/common for Node's test/parallel files gains isAIX, isIBMi and isSunOS. --- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../lowering/builtins/operating-system.ts | 9 +- .../src/frontend/lowering/surfaces.ts | 2 +- .../compiler/src/ir/builtin-signatures.ts | 15 +- .../corpus/os-userinfo-passwd-entry.ts.json | 16 ++ .../process-emit-warning-arguments.cjs.json | 6 + .../corpus/process-kill-chdir-errors.cjs.json | 6 + packages/runtime/src/scr_async_dyn.c | 191 +++++++++++------- packages/runtime/src/scr_child.c | 4 +- packages/runtime/src/scr_lib.c | 100 ++++++--- packages/runtime/src/scr_os.h | 8 +- tests/corpus/os-userinfo-passwd-entry.ts | 42 ++++ .../corpus/process-emit-warning-arguments.cjs | 103 ++++++++++ tests/corpus/process-kill-chdir-errors.cjs | 26 +++ .../os-userinfo-passwd-entry.ts.json | 1 + .../process-emit-warning-arguments.cjs.json | 1 + .../process-kill-chdir-errors.cjs.json | 1 + tests/node-parallel/common/index.cjs | 7 + 18 files changed, 429 insertions(+), 111 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-emit-warning-arguments.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-kill-chdir-errors.cjs.json create mode 100644 tests/corpus/os-userinfo-passwd-entry.ts create mode 100644 tests/corpus/process-emit-warning-arguments.cjs create mode 100644 tests/corpus/process-kill-chdir-errors.cjs create mode 100644 tests/harness/corpus-costs/os-userinfo-passwd-entry.ts.json create mode 100644 tests/harness/corpus-costs/process-emit-warning-arguments.cjs.json create mode 100644 tests/harness/corpus-costs/process-kill-chdir-errors.cjs.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 73fd0b799..35adf72f2 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -261,6 +261,8 @@ export const LIB_FN_SYMS: Record = { "os.userName": "scr_os_user_name", "os.userShell": "scr_os_user_shell", "os.userHomedir": "scr_os_user_homedir", + "os.userUid": "scr_os_user_uid", + "os.userGid": "scr_os_user_gid", "os.tmpdir": "scr_os_tmpdir", "process.argv": "scr_process_argv", "process.execArgv": "scr_process_exec_argv", diff --git a/packages/compiler/src/frontend/lowering/builtins/operating-system.ts b/packages/compiler/src/frontend/lowering/builtins/operating-system.ts index ee189a1f3..3f3616f23 100644 --- a/packages/compiler/src/frontend/lowering/builtins/operating-system.ts +++ b/packages/compiler/src/frontend/lowering/builtins/operating-system.ts @@ -17,8 +17,9 @@ import { } from "../../../ir/ir.js"; /** `os.userInfo()` — the passwd-entry snapshot, Node's uv_os_get_passwd - * behind the call site's own mapped record shape: username = pw_name, - * uid/gid = getuid/getgid, shell = pw_shell (as the `string | null` + * for the effective uid behind the call site's own mapped record shape: + * username = pw_name, uid/gid = pw_uid/pw_gid (the user's primary group, + * not the process gid; -1 on Windows), shell = pw_shell (as the `string | null` * union @types/node declares — POSIX always answers the string arm; * null is Node's Windows answer), homedir = pw_dir (the PASSWD home, * NOT os.homedir's $HOME-first cascade — Node's own split). The record @@ -56,12 +57,12 @@ export function lowerOsUserInfoCall( } else if (f.name === "uid" && f.type.kind === "f64") { fields.push({ name: f.name, - value: { kind: "libCall", fn: "process.getuid", args: [], type: F64, loc }, + value: { kind: "libCall", fn: "os.userUid", args: [], type: F64, loc }, }); } else if (f.name === "gid" && f.type.kind === "f64") { fields.push({ name: f.name, - value: { kind: "libCall", fn: "process.getgid", args: [], type: F64, loc }, + value: { kind: "libCall", fn: "os.userGid", args: [], type: F64, loc }, }); } else if (f.name === "homedir" && f.type.kind === "string") { fields.push({ diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index fc2a5f389..5d79ea551 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2318,7 +2318,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< }, os: { // lowerOsUserInfoCall assembles the record from scalar libCalls. - userInfo: ["os.userHomedir", "os.userShell"], + userInfo: ["os.userHomedir", "os.userShell", "os.userUid", "os.userGid"], // lowerOsCpusCall reads the cpusTake snapshot through its accessors. cpus: ["os.cpusModel", "os.cpusField"], }, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3287ac54a..633391a0f 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -808,15 +808,18 @@ function buildLibFnSignatures() { "fs.realpathValue": { argTypes: [DYN, DYN, BOOL], result: DYN }, /** Native realpath with syscall "realpath" on failure. +1 fresh string. */ "fs.realpathNativeSync": { argTypes: [STRING], result: STRING }, - /** The os.userInfo() field trio (uv_os_get_passwd's slices): pw_name, - * pw_shell, pw_dir — the PASSWD homedir, not os.homedir's $HOME-first - * cascade (Node's own split). The frontend assembles the UserInfo - * record from these plus getuid/getgid. +1 fresh strings; a passwd - * lookup failure aborts (Node throws a system error there — no - * compiled program path reaches it for the running uid). */ + /** The os.userInfo() fields (uv_os_get_passwd's slices of the passwd + * entry for the effective uid): pw_name, pw_shell, pw_dir — the PASSWD + * homedir, not os.homedir's $HOME-first cascade (Node's own split) — + * and pw_uid, pw_gid (-1 on Windows). The frontend assembles the + * UserInfo record from these. +1 fresh strings; a passwd lookup + * failure aborts (Node throws a system error there — no compiled + * program path reaches it for the running uid). */ "os.userName": { argTypes: [], result: STRING }, "os.userShell": { argTypes: [], result: STRING }, "os.userHomedir": { argTypes: [], result: STRING }, + "os.userUid": { argTypes: [], result: F64 }, + "os.userGid": { argTypes: [], result: F64 }, "os.tmpdir": { argTypes: [], result: STRING }, // Result is the call site's Dict record (VOID is // the dgram.address sentinel — the libCall case checks the structure). diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json new file mode 100644 index 000000000..8d287746c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json @@ -0,0 +1,16 @@ +{ + "order": [ + "/tests/corpus/os-userinfo-passwd-entry.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Namespace 'NodeJS' has no exported member 'ProcessEnv'.", + "loc": { + "file": "/tests/corpus/os-userinfo-passwd-entry.ts", + "start": 1578, + "end": 1588 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-emit-warning-arguments.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-emit-warning-arguments.cjs.json new file mode 100644 index 000000000..7fb8d4ad2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-emit-warning-arguments.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-emit-warning-arguments.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-kill-chdir-errors.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-kill-chdir-errors.cjs.json new file mode 100644 index 000000000..6bb0c6cf1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-kill-chdir-errors.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-kill-chdir-errors.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 1d8febbc3..cd93defcc 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -932,7 +932,32 @@ bool scr_process_deprecation_flag_on(int which) { /* The warning's name as warning.toString() reads it: the own property, or * the Error class name an emitted Error instance inherits. +1 or NULL. */ +/* A warning's `key` as a property read sees it, including the fields of an + * Error subclass instance: +1, or NULL when there is none or the read threw + * (the exception is pending). */ +static ScrDyn *scr_warning_field(const ScrDyn *w, const char *key, size_t key_len) { + if (w->kind == SCR_DYN_TYPED_REF) { + /* The reference's own answer, else its materialized view's. */ + ScrStr *name = scr_str_new(key, key_len); + ScrDyn *value = scr_dyn_typed_ref_key_get(w, name); + scr_str_release(name); + if (value || scr_exc_pending()) return value; + ScrDyn *view = scr_dyn_typed_ref_materialize(w); + if (view && !scr_exc_pending()) value = scr_warning_field(view, key, key_len); + scr_dyn_release(view); + return value; + } + if (w->kind != SCR_DYN_OBJ) return NULL; + return scr_dyn_obj_read(w, key, key_len); +} + static ScrStr *scr_warning_name(const ScrDyn *w) { + if (w->kind == SCR_DYN_TYPED_REF) { + ScrDyn *en = scr_warning_field(w, "name", 4); + ScrStr *name = en && en->kind == SCR_DYN_STR ? scr_str_retain(en->v.str) : NULL; + scr_dyn_release(en); + if (name) return name; + } const ScrDyn *en = scr_dyn_obj_get(w, "name", 4); if (en) return en->kind == SCR_DYN_STR ? scr_str_retain(en->v.str) : NULL; ScrError *e = scr_errdyn_err_of(w); @@ -960,9 +985,17 @@ static void scr_warning_dispatch(ScrDyn *w) { bool deprecation = scr_warning_is_deprecation(w); if (deprecation && scr_process_deprecation_flag_on(0)) goto listeners; ScrStr *name = scr_warning_name(w); - const ScrDyn *em = scr_dyn_obj_get(w, "message", 7); - const ScrDyn *ec = scr_dyn_obj_get(w, "code", 4); - const ScrDyn *ed = scr_dyn_obj_get(w, "detail", 6); + ScrDyn *em = scr_warning_field(w, "message", 7); + ScrDyn *ec = scr_exc_pending() ? NULL : scr_warning_field(w, "code", 4); + ScrDyn *ed = scr_exc_pending() ? NULL : scr_warning_field(w, "detail", 6); + if (scr_exc_pending()) { + /* A read that threw reports nothing; the exception propagates. */ + scr_str_release(name); + scr_dyn_release(em); + scr_dyn_release(ec); + scr_dyn_release(ed); + return; + } ScrJsonBuf report; scr_jb_init(&report); char pid[32]; @@ -982,6 +1015,9 @@ static void scr_warning_dispatch(ScrDyn *w) { scr_jb_puts(&report, "\n"); scr_jb_put_str(&report, ed->v.str); } + scr_dyn_release(em); + scr_dyn_release(ec); + scr_dyn_release(ed); /* Node's one-time trace hint, after the first report; deprecations * name their own trace flag. */ if (!scr_warning_trace_hint_shown) { @@ -1086,8 +1122,9 @@ static void scr_warning_tick(ScrClosure *c) { } static void scr_warning_throw_tick(ScrClosure *c) { - ScrDyn *w = scr_box_get_ref(c->caps[0]); - scr_throw_ref(w, scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + /* Thrown as the Error it encodes, so the uncaught report and catch + * bindings see an Error. */ + scr_dyn_throw(scr_box_get_ref(c->caps[0])); } static void scr_warning_defer(ScrDyn *w, void (*fire)(ScrClosure *)) { @@ -1199,18 +1236,76 @@ void scr_process_emit_warning_text(ScrStr *message) { scr_next_tick_raw(scr_warn_text_tick); } -static void scr_warn_bad_arg(const char *arg, const char *want) { - char buf[160]; - int n = snprintf(buf, sizeof buf, "The \"%s\" argument must be of type %s", arg, want); - scr_throw_error_msg_code(SCR_ERR_TYPE, buf, (size_t)n, "ERR_INVALID_ARG_TYPE"); +/* A dyn value of kind STR equal to `text`. */ +static bool scr_warn_str_is(const ScrDyn *value, const char *text) { + size_t n = strlen(text); + return value != NULL && value->kind == SCR_DYN_STR && value->v.str->len == n && + memcmp(value->v.str->data, text, n) == 0; +} + +/* process.emitWarning(warning, type, code, ctor) in the order Node's + * lib/internal/process/warning.js runs it: + * - a "DeprecationWarning" type string returns at once while + * process.noDeprecation is set; + * - an options object (any non-array object) replaces type with + * `options.type || "Warning"` and code with `options.code`, and keeps + * a string `options.detail`; a function in the type position is the + * ctor and clears code; + * - type, when not undefined, and code, when neither undefined nor a + * function, must be strings, for Error warnings too; + * - the warning must be a string or an Error. + * Wrong kinds throw ERR_INVALID_ARG_TYPE TypeErrors with Node's message. + * A string warning becomes an Error named `type || "Warning"`. The warning + * is emitted on the next tick. Borrows its arguments; NULL is an omitted + * argument. */ +static void scr_emit_warning_args(const ScrDyn *warning, const ScrDyn *type, const ScrDyn *code) { + const ScrDyn *detail = NULL; + if (scr_warn_str_is(type, "DeprecationWarning") && scr_process_deprecation_flag_on(0)) return; + if (type != NULL && type->kind == SCR_DYN_OBJ) { + const ScrDyn *t = scr_dyn_obj_get(type, "type", 4); + const ScrDyn *d = scr_dyn_obj_get(type, "detail", 6); + code = scr_dyn_obj_get(type, "code", 4); + if (d != NULL && d->kind == SCR_DYN_STR) detail = d; + type = t != NULL && scr_dyn_truthy(t) ? t : NULL; + } else if (type != NULL && type->kind == SCR_DYN_FUNC) { + type = NULL; + code = NULL; + } + if (type != NULL && type->kind == SCR_DYN_UNDEF) type = NULL; + if (type != NULL && type->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("type", "of type string", type); + return; + } + if (code != NULL && (code->kind == SCR_DYN_FUNC || code->kind == SCR_DYN_UNDEF)) code = NULL; + if (code != NULL && code->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("code", "of type string", code); + return; + } + /* An Error warning, an instance of an Error subclass included, keeps + * its own name and code (Node). */ + ScrError *error = scr_errdyn_err_of(warning); + if (error != NULL || (warning->kind == SCR_DYN_OBJ && scr_dyn_obj_get(warning, "%error", 6) != NULL) || + scr_dyn_ref_is_error(warning)) { + scr_error_release(error); + scr_warning_emit_deferred((ScrDyn *)warning); + return; + } + if (warning->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("warning", "of type string or an instance of Error", warning); + return; + } + ScrError *e = scr_error_new(SCR_ERR_ERROR, warning->v.str); + scr_str_release(e->name); + e->name = type != NULL && type->v.str->len > 0 ? scr_str_retain(type->v.str) : scr_str_new("Warning", 7); + e->name_present = true; + if (code != NULL) e->code = scr_str_retain(code->v.str); + if (detail != NULL) scr_error_set_prop(e, "detail", 6, scr_dyn_new_str(detail->v.str)); /* retains */ + ScrDyn *w = scr_dyn_from_error(e); + scr_error_release(e); + scr_warning_emit_deferred(w); + scr_dyn_release(w); } -/* process.emitWarning(...) — Node's full argument grammar over dyn - * values: (warning: string | Error), then for string warnings a type - * string / ctor function / options object ({type, code, detail}) second - * and a code string / ctor function third. Wrong kinds throw Node's - * ERR_INVALID_ARG_TYPE TypeErrors at the call; non-string details are - * ignored. The warning itself is emitted on the next tick. */ void scr_process_emit_warning(ScrDyn *args) { #ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { @@ -1221,65 +1316,19 @@ void scr_process_emit_warning(ScrDyn *args) { #endif size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; ScrDyn *const *items = args->kind == SCR_DYN_ARR ? args->v.arr.items : NULL; - ScrDyn *warning = argc >= 1 ? items[0] : NULL; - /* An Error-encoded warning: type/code arguments are ignored (Node). */ - if (warning != NULL && warning->kind == SCR_DYN_OBJ && - scr_dyn_obj_get(warning, "%error", 6) != NULL) { - scr_warning_emit_deferred(warning); - return; - } - if (warning == NULL || warning->kind != SCR_DYN_STR) { - scr_warn_bad_arg("warning", "string or an instance of Error"); - return; - } - const ScrStr *type = NULL; - const ScrStr *code = NULL; - const ScrStr *detail = NULL; - size_t i = 1; - if (i < argc && items[i]->kind != SCR_DYN_UNDEF) { - ScrDyn *a = items[i]; - if (a->kind == SCR_DYN_STR) { - type = a->v.str; - i++; - } else if (a->kind == SCR_DYN_FUNC) { - i = argc; /* ctor: consumed, stack-trace trimming only — ignored */ - } else if (a->kind == SCR_DYN_OBJ && scr_dyn_obj_get(a, "%error", 6) == NULL) { - const ScrDyn *t = scr_dyn_obj_get(a, "type", 4); - const ScrDyn *c = scr_dyn_obj_get(a, "code", 4); - const ScrDyn *d = scr_dyn_obj_get(a, "detail", 6); - if (t && t->kind == SCR_DYN_STR) type = t->v.str; - else if (t && t->kind != SCR_DYN_UNDEF) { - scr_warn_bad_arg("options.type", "string"); - return; - } - if (c && c->kind == SCR_DYN_STR) code = c->v.str; - if (d && d->kind == SCR_DYN_STR) detail = d->v.str; - i = argc; /* options form: no third argument (Node ignores it) */ - } else { - scr_warn_bad_arg("type", "string"); + const ScrDyn *type = argc >= 2 ? items[1] : NULL; + /* A typed record in the options position reads as the object it is. */ + ScrDyn *options = NULL; + if (type != NULL && type->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_array(type)) { + options = scr_dyn_typed_ref_materialize(type); + if (options == NULL || scr_exc_pending()) { + scr_dyn_release(options); return; } - } else if (i < argc) { - i++; /* explicit undefined type: default */ + type = options; } - if (i < argc && items[i]->kind != SCR_DYN_UNDEF) { - ScrDyn *a = items[i]; - if (a->kind == SCR_DYN_STR) code = a->v.str; - else if (a->kind != SCR_DYN_FUNC) { - scr_warn_bad_arg("code", "string"); - return; - } - } - ScrError *e = scr_error_new(SCR_ERR_ERROR, (ScrStr *)warning->v.str); - scr_str_release(e->name); - e->name = type ? scr_str_retain((ScrStr *)type) : scr_str_new("Warning", 7); - e->name_present = true; - if (code) e->code = scr_str_retain((ScrStr *)code); - if (detail) scr_error_set_prop(e, "detail", 6, scr_dyn_new_str((ScrStr *)detail)); /* retains */ - ScrDyn *w = scr_dyn_from_error(e); - scr_error_release(e); - scr_warning_emit_deferred(w); - scr_dyn_release(w); + scr_emit_warning_args(argc >= 1 ? items[0] : scr_dyn_undefined(), type, argc >= 3 ? items[2] : NULL); + scr_dyn_release(options); } diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index d4f9ca395..93169be80 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -2593,7 +2593,7 @@ bool scr_child_kill(ScrChild *c, const ScrStr *signal) { char *msg = malloc(cap); if (!msg) scr_child_oom(); int len = snprintf(msg, cap, "Unknown signal: %s", signal->data); - scr_throw_error_msg(SCR_ERR_TYPE, msg, (size_t)len); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_UNKNOWN_SIGNAL"); free(msg); return false; } @@ -5462,7 +5462,7 @@ bool scr_child_kill(ScrChild *c, const ScrStr *signal) { char *msg = malloc(cap); if (!msg) scr_child_oom(); int len = snprintf(msg, cap, "Unknown signal: %s", signal->data); - scr_throw_error_msg(SCR_ERR_TYPE, msg, (size_t)len); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_UNKNOWN_SIGNAL"); free(msg); return false; } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9ef71a223..175f2e980 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -1370,22 +1370,23 @@ static int scr_wasi_kill(int pid, int sig) { #define scr_sys_kill(pid, sig) kill((pid_t)(pid), (sig)) #endif +static const char *scr_errno_name(int e, char *fallback, size_t cap); +static const char *scr_errno_text(int e); +static ScrError *scr_fs_error_details(int e, const char *name, const char *message, size_t length, + const char *op, const ScrStr *path, const ScrStr *dest); + +/* Node's errnoException(err, "kill"): message "kill ", with own + * errno (the negative uv code), code and syscall properties. */ static bool scr_kill_send(int pid, int sig) { if (scr_sys_kill(pid, sig) == 0) return true; - const char *name = errno == ESRCH ? "ESRCH" - : errno == EPERM ? "EPERM" - : errno == EINVAL ? "EINVAL" - : NULL; + int e = errno; + char namebuf[16]; + const char *name = scr_errno_name(e, namebuf, sizeof namebuf); char msg[32]; - int len; - if (name) { - len = snprintf(msg, sizeof msg, "kill %s", name); - /* Node's errnoException carries code = the errno name. */ - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); - } else { - len = snprintf(msg, sizeof msg, "kill E%d", errno); - scr_throw_error_msg(SCR_ERR_ERROR, msg, (size_t)len); - } + int len = snprintf(msg, sizeof msg, "kill %s", name); + ScrError *error = scr_fs_error_details(e, name, msg, (size_t)len, "kill", NULL, NULL); + error->errno_order = true; + scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); return false; } @@ -1405,7 +1406,7 @@ bool scr_process_kill_named(double pid, const ScrStr *signal) { scr_trap("scriptc: out of memory\n"); } int len = snprintf(msg, cap, "Unknown signal: %s", signal->data); - scr_throw_error_msg(SCR_ERR_TYPE, msg, (size_t)len); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_UNKNOWN_SIGNAL"); free(msg); return false; } @@ -1489,6 +1490,10 @@ ScrStr *scr_os_user_homedir(void) { return scr_os_homedir(); /* uv_os_get_passwd reuses uv_os_homedir on win */ } +/* uv_os_get_passwd on Windows has no uid or gid: both are -1. */ +double scr_os_user_uid(void) { return -1; } +double scr_os_user_gid(void) { return -1; } + ScrStr *scr_os_release(void) { /* uv_os_uname on Windows: RtlGetVersion (the un-lied-to GetVersionEx), * rendered "major.minor.build" — Node answers e.g. "10.0.26100". */ @@ -1554,6 +1559,8 @@ ScrStr *scr_os_user_name(void) { } ScrStr *scr_os_user_shell(void) { return scr_str_new("", 0); } ScrStr *scr_os_user_homedir(void) { return scr_os_homedir(); } +double scr_os_user_uid(void) { return -1; } +double scr_os_user_gid(void) { return -1; } ScrStr *scr_os_release(void) { return scr_str_new("", 0); } ScrStr *scr_os_type(void) { return scr_str_new("WASI", 4); } double scr_os_totalmem(void) { return 0; } @@ -1565,26 +1572,27 @@ double scr_os_available_parallelism(void) { return 1; } ScrStr *scr_os_tmpdir(void) { return scr_str_new("/tmp", 4); } #else ScrStr *scr_os_homedir(void) { - /* uv_os_homedir: $HOME when set (even empty is "set" only if non-NULL; - * libuv requires non-empty), else the passwd entry. */ + /* uv_os_homedir: $HOME whenever it is set, an empty value included, + * else the passwd entry of the effective uid. */ const char *home = scr_getenv("HOME"); - if (home && home[0] != '\0') return scr_str_new(home, strlen(home)); + if (home) return scr_str_new(home, strlen(home)); struct passwd pw; struct passwd *result = NULL; char buf[8192]; - if (getpwuid_r(getuid(), &pw, buf, sizeof buf, &result) != 0 || !result || !result->pw_dir) { + if (getpwuid_r(geteuid(), &pw, buf, sizeof buf, &result) != 0 || !result || !result->pw_dir) { scr_trap("scriptc: os.homedir() failed\n"); } return scr_str_new(result->pw_dir, strlen(result->pw_dir)); } -/* The os.userInfo() field trio — uv_os_get_passwd's slices. One passwd - * lookup per call (three calls per userInfo record — cheap, no caching - * to invalidate). Failure aborts: Node throws a system error there, but - * no compiled program path reaches it for the running uid. */ +/* The os.userInfo() fields — uv_os_get_passwd's slices of the passwd + * entry for the effective uid. One passwd lookup per call (one call per + * userInfo field — cheap, no caching to invalidate). Failure aborts: Node + * throws a system error there, but no compiled program path reaches it + * for the running uid. */ static const struct passwd *scr_os_passwd(char *buf, size_t cap, struct passwd *pw) { struct passwd *result = NULL; - if (getpwuid_r(getuid(), pw, buf, cap, &result) != 0 || !result) { + if (getpwuid_r(geteuid(), pw, buf, cap, &result) != 0 || !result) { scr_trap("scriptc: os.userInfo() failed\n"); } return result; @@ -1614,6 +1622,20 @@ ScrStr *scr_os_user_homedir(void) { return scr_str_new(r->pw_dir, strlen(r->pw_dir)); } +/* The entry's pw_uid and pw_gid: the user's primary group, which can + * differ from the process gid. */ +double scr_os_user_uid(void) { + struct passwd pw; + char buf[8192]; + return (double)scr_os_passwd(buf, sizeof buf, &pw)->pw_uid; +} + +double scr_os_user_gid(void) { + struct passwd pw; + char buf[8192]; + return (double)scr_os_passwd(buf, sizeof buf, &pw)->pw_gid; +} + ScrStr *scr_os_release(void) { /* uname(2)'s release field — Node's uv_os_uname()-backed answer. */ struct utsname u; @@ -2983,6 +3005,33 @@ double scr_process_umask(double mask) { #endif } +/* Node's chdir failure: the uv exception carries the working directory + * as path and the target, as given, as dest ("ENOENT: no such file or + * directory, chdir '' -> ''"). */ +static void scr_process_chdir_throw(int e, const ScrStr *dir) { +#ifdef _WIN32 + ScrStr *cwd = scr_process_cwd(); +#else + char buf[4096]; + ScrStr *cwd = getcwd(buf, sizeof buf) ? scr_str_new(buf, strlen(buf)) : NULL; +#endif + if (cwd == NULL) { + scr_fs_throw(e, "chdir", dir); + return; + } + char namebuf[16]; + const char *name = scr_errno_name(e, namebuf, sizeof namebuf); + const char *text = scr_errno_text(e); + size_t cap = strlen(name) + strlen(text) + cwd->len + dir->len + 24; + char *msg = malloc(cap); + if (!msg) scr_trap("scriptc: out of memory\n"); + int len = snprintf(msg, cap, "%s: %s, chdir '%s' -> '%s'", name, text, cwd->data, dir->data); + ScrError *error = scr_fs_error_details(e, name, msg, (size_t)len, "chdir", cwd, dir); + free(msg); + scr_str_release(cwd); + scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + void scr_process_chdir(ScrStr *dir) { #ifdef SCR_WORKERS if (!scr_context_is_main()) { @@ -2996,9 +3045,9 @@ void scr_process_chdir(ScrStr *dir) { BOOL changed = wide && SetCurrentDirectoryW(wide); DWORD error = changed ? ERROR_SUCCESS : GetLastError(); free(wide); - if (!changed) scr_fs_throw(scr_fs_win_errno(error), "chdir", dir); + if (!changed) scr_process_chdir_throw(scr_fs_win_errno(error), dir); #else - if (chdir(dir->data) != 0) scr_fs_throw(errno, "chdir", dir); + if (chdir(dir->data) != 0) scr_process_chdir_throw(errno, dir); #endif } @@ -3065,6 +3114,7 @@ static const char *scr_errno_name(int e, char *fallback, size_t cap) { case ELOOP: return "ELOOP"; case ENOSYS: return "ENOSYS"; case ENOTSUP: return "ENOTSUP"; + case ESRCH: return "ESRCH"; default: snprintf(fallback, cap, "E%d", e); return fallback; diff --git a/packages/runtime/src/scr_os.h b/packages/runtime/src/scr_os.h index 41bee5a8f..eab3a017b 100644 --- a/packages/runtime/src/scr_os.h +++ b/packages/runtime/src/scr_os.h @@ -48,11 +48,15 @@ ScrDyn *scr_fs_constants_object(void); * and frozen (+1). */ ScrDyn *scr_node_constants(const ScrDyn *crypto_table); double scr_os_available_parallelism(void); /* uv_available_parallelism: CPUs this process may use, >= 1 */ -/* os.userInfo()'s field trio (pw_name / pw_shell / pw_dir — the passwd - * home, not the $HOME cascade). +1 fresh; abort on lookup failure. */ +/* os.userInfo()'s fields from the passwd entry of the effective uid + * (pw_name / pw_shell / pw_dir — the passwd home, not the $HOME cascade — + * and pw_uid / pw_gid, -1 on Windows). Strings +1 fresh; abort on lookup + * failure. */ ScrStr *scr_os_user_name(void); ScrStr *scr_os_user_shell(void); ScrStr *scr_os_user_homedir(void); +double scr_os_user_uid(void); +double scr_os_user_gid(void); /* fs.readdirSync(path, { withFileTypes: true }): one readdir pass * snapshotting name + entry kind (scr_lib.c) — the emitter walks it to diff --git a/tests/corpus/os-userinfo-passwd-entry.ts b/tests/corpus/os-userinfo-passwd-entry.ts new file mode 100644 index 000000000..71faebccb --- /dev/null +++ b/tests/corpus/os-userinfo-passwd-entry.ts @@ -0,0 +1,42 @@ +// os.userInfo() reads the passwd entry of the effective uid, so uid and +// gid are the entry's pw_uid and pw_gid (the user's primary group, which +// can differ from the process gid). os.homedir() answers $HOME whenever it +// is set, an empty value included. The program re-runs itself with HOME +// set to "" and with HOME removed, and prints each child's answer. +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import * as os from "node:os"; + +const mode = process.argv[2]; +if (mode === "home") { + console.log(JSON.stringify(os.homedir())); +} else { + const info = os.userInfo(); + console.log(Object.keys(info).join(",")); + // The /etc/passwd line for this user, when the file lists it. + let entry: string[] | undefined; + if (existsSync("/etc/passwd")) { + for (const line of readFileSync("/etc/passwd", "utf8").split("\n")) { + const fields = line.split(":"); + if (fields.length >= 7 && fields[0] === info.username) entry = fields; + } + } + if (entry) { + console.log("uid matches entry:", String(info.uid) === entry[2]); + console.log("gid matches entry:", String(info.gid) === entry[3]); + console.log("homedir matches entry:", info.homedir === entry[5]); + console.log("shell matches entry:", info.shell === entry[6]); + } else { + console.log("no passwd entry"); + } + const empty = spawnSync(process.execPath, [process.argv[1], "home"], { + encoding: "utf8", + env: { ...process.env, HOME: "" }, + }); + console.log("HOME empty:", empty.status, empty.stdout.trim()); + const env = { ...process.env }; + delete env.HOME; + const unset = spawnSync(process.execPath, [process.argv[1], "home"], { encoding: "utf8", env }); + const fromPasswd = entry ? JSON.stringify(entry[5]) : JSON.stringify(info.homedir); + console.log("HOME unset is passwd home:", unset.status, unset.stdout.trim() === fromPasswd); +} diff --git a/tests/corpus/process-emit-warning-arguments.cjs b/tests/corpus/process-emit-warning-arguments.cjs new file mode 100644 index 000000000..fefadd1af --- /dev/null +++ b/tests/corpus/process-emit-warning-arguments.cjs @@ -0,0 +1,103 @@ +// process.emitWarning's argument handling, in the order Node runs it: an +// options object replaces type with `options.type || "Warning"` and code +// with `options.code`; a function in the type position is the ctor; type +// and code are checked before the warning itself, for Error warnings too; +// an empty type names the warning "Warning". Wrong kinds throw +// ERR_INVALID_ARG_TYPE with Node's "Received ..." message. Under +// process.throwDeprecation the thrown deprecation is reported as the +// DeprecationWarning it is. The program re-runs itself and prints the +// child's output with process ids normalized. +"use strict"; +const { spawnSync } = require("child_process"); + +class CustomWarning extends Error { + constructor() { + super("custom"); + this.name = "CustomWarning"; + this.code = "CW1"; + } +} + +const mode = process.argv[2]; +if (mode === "child") { + process.on("warning", (w) => console.log("listener:", w.name, w.message, w.code, w.detail)); + // Untyped parameters: the arguments reach emitWarning as given. An + // argument left out is undefined, which emitWarning treats as omitted. + function emit(warning, type, code) { + process.emitWarning(warning, type, code); + } + const ctor = function marker() {}; + const forms = [ + ["plain"], + ["empty type", ""], + ["undefined type", undefined, "C3"], + ["typed", "TypedWarning", "C4"], + ["ctor type", ctor], + ["ctor code", "T5", ctor], + ["options", { type: "OptWarning", code: "C6", detail: "more" }], + ["options empty type", { type: "" }], + ["options only code", { code: "C7" }], + ["options zero type", { type: 0 }], + ["options detail ignored", { type: "D", detail: 1 }], + ["options third ignored", { type: "E" }, 5], + [new Error("error warning"), "Ignored", "IGNORED"], + ]; + for (const args of forms) emit(args[0], args[1], args[2]); + // An instance of an Error subclass keeps its own name and code. + process.emitWarning(new CustomWarning()); + emit(new CustomWarning(), "Ignored"); + + const invalid = [ + [1], + [{}], + [true], + [[]], + [null], + [], + ["", 1], + ["", null], + ["", ["a"]], + ["", true], + ["", "", 1], + ["", "", {}], + ["", "", true], + ["", "", []], + ["", { type: 5 }], + ["", { type: true }], + ["", { code: 5 }], + ["", { code: null }], + [new Error("e"), 5], + [new Error("e"), "T", 5], + [5, 5], + [undefined, "foo", "bar"], + [undefined], + ]; + for (const args of invalid) { + try { + emit(args[0], args[1], args[2]); + console.log("no throw:", args.length); + } catch (e) { + console.log(e.name, e.code, e.message); + } + } + + // The deprecation fast path returns before any argument is checked. + process.noDeprecation = true; + emit(5, "DeprecationWarning"); + process.emitWarning("dropped", "DeprecationWarning", "DEP_X"); + process.noDeprecation = false; + console.log("end of turn"); +} else if (mode === "throw") { + process.throwDeprecation = true; + process.emitWarning("thrown deprecation", "DeprecationWarning", "DEP_THROWN"); + console.log("before the tick"); +} else { + const child = spawnSync(process.execPath, [process.argv[1], "child"], { encoding: "utf8" }); + console.log(`status: ${child.status}`); + console.log(child.stdout); + console.log(child.stderr.replace(/\(node:\d+\)/g, "(node:)")); + const thrown = spawnSync(process.execPath, [process.argv[1], "throw"], { encoding: "utf8" }); + console.log(`throw status: ${thrown.status}`); + console.log(thrown.stdout); + console.log(thrown.stderr.includes("DeprecationWarning: thrown deprecation")); +} diff --git a/tests/corpus/process-kill-chdir-errors.cjs b/tests/corpus/process-kill-chdir-errors.cjs new file mode 100644 index 000000000..4f67a2382 --- /dev/null +++ b/tests/corpus/process-kill-chdir-errors.cjs @@ -0,0 +1,26 @@ +// process.kill and process.chdir failures carry Node's error properties: +// an unknown signal name throws ERR_UNKNOWN_SIGNAL; a failed kill(2) is +// Node's errnoException ("kill ESRCH" with errno, code and syscall); a +// failed chdir names the working directory as path and the target as dest +// in "chdir '' -> ''". +"use strict"; + +function report(label, run) { + try { + run(); + console.log(label, "no throw"); + } catch (err) { + const cwd = process.cwd(); + console.log(label, err.name, err.code, err.message.replace(cwd, "")); + console.log(" errno:", err.errno, "syscall:", err.syscall); + console.log(" path is cwd:", err.path === cwd, "dest:", err.dest); + console.log(" keys:", Object.keys(err).join(",")); + } +} + +report("unknown signal", () => process.kill(process.pid, "SIGNOPE")); +// A pid above the kernel's pid_max (2^22) names no process. +report("missing process", () => process.kill(4194304 + 12345, 0)); +report("chdir missing", () => process.chdir("/does/not/exist/kill-chdir")); +report("chdir file", () => process.chdir("/dev/null")); +console.log(process.kill(process.pid, 0)); diff --git a/tests/harness/corpus-costs/os-userinfo-passwd-entry.ts.json b/tests/harness/corpus-costs/os-userinfo-passwd-entry.ts.json new file mode 100644 index 000000000..1ede551d9 --- /dev/null +++ b/tests/harness/corpus-costs/os-userinfo-passwd-entry.ts.json @@ -0,0 +1 @@ +{ "plain": 3493, "san": 1546 } diff --git a/tests/harness/corpus-costs/process-emit-warning-arguments.cjs.json b/tests/harness/corpus-costs/process-emit-warning-arguments.cjs.json new file mode 100644 index 000000000..a9cb90039 --- /dev/null +++ b/tests/harness/corpus-costs/process-emit-warning-arguments.cjs.json @@ -0,0 +1 @@ +{ "plain": 5313, "san": 2297 } diff --git a/tests/harness/corpus-costs/process-kill-chdir-errors.cjs.json b/tests/harness/corpus-costs/process-kill-chdir-errors.cjs.json new file mode 100644 index 000000000..9b566ebf8 --- /dev/null +++ b/tests/harness/corpus-costs/process-kill-chdir-errors.cjs.json @@ -0,0 +1 @@ +{ "plain": 3123, "san": 1390 } diff --git a/tests/node-parallel/common/index.cjs b/tests/node-parallel/common/index.cjs index b52bba638..098351161 100644 --- a/tests/node-parallel/common/index.cjs +++ b/tests/node-parallel/common/index.cjs @@ -4,11 +4,15 @@ // test/common/index.js. Add a helper here, with Node's semantics, when a // lane's files need it. const assert = require("assert"); +const os = require("os"); const { inspect } = require("util"); const hasIntl = true; const isDebug = false; const isWindows = process.platform === "win32"; +const isAIX = os.type() === "AIX"; +const isIBMi = os.type() === "OS400"; +const isSunOS = process.platform === "sunos"; const mustCallChecks = []; function runCallChecks(exitCode) { @@ -119,7 +123,10 @@ function spawnPromisified(...args) { module.exports = { hasIntl, + isAIX, isDebug, + isIBMi, + isSunOS, isWindows, mustCall, mustCallAtLeast, From a7b673e4ffb311ab6e8d8858c927dc13fbaace64 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:32:36 -0500 Subject: [PATCH 049/193] Keep path module objects and bound path functions Node's values in untyped code A path family read by untyped code is a plain object holding Node's members in Node's order, built once per family and kept in the builtin-module registry. Object.keys, Object.entries, Object.assign, spreads, for-in and own-property checks over it see those members. path.posix.posix and the cross-family members refer to the registered objects; the registry releases their members at exit through the objects' own storage. Every builtin function value that becomes untyped boxes as its checked twin, under the builtin's name and length, after lowering: a module object's member, an assertion's argument, and a read of a binding whose only stores are that builtin (a const local, or a module-level binding). `ns.join === path.posix.join` holds for an untyped path namespace, and `const join = path.join` passed to an untyped function compares equal to path.join and validates its arguments with Node's errors. The lowerer journals the checked-twin table (each builtin's data, display name and location), so a unit replayed from the journal keeps its twins. --- .../limitations/builtin-function-values.mdx | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../frontend/lowering/lower-builtin-values.ts | 146 ++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 19 ++- .../src/frontend/lowering/surfaces.ts | 7 +- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 1 + .../path-module-values-untyped.cjs.json | 6 + packages/runtime/src/scr_blob.h | 1 + packages/runtime/src/scr_node_builtin.c | 53 ++++++- tests/corpus/path-module-values-untyped.cjs | 82 ++++++++++ .../path-module-values-untyped.cjs.json | 1 + 12 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-module-values-untyped.cjs.json create mode 100644 tests/corpus/path-module-values-untyped.cjs create mode 100644 tests/harness/corpus-costs/path-module-values-untyped.cjs.json diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index d650eef3f..f6a14be60 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -2,6 +2,7 @@ Some builtins can be stored and passed as functions: - Lowered `node:path` functions across the bare, POSIX, and win32 modules, including optional `basename` and variadic `join`/`resolve`. Called through an untyped value, they validate their arguments with Node's `ERR_INVALID_ARG_TYPE`. + A path module held by untyped code is a plain object with Node's members in Node's order. - Supported zero-argument `node:os` functions and `querystring.escape`/`unescape`. - `util.isDeepStrictEqual`, `styleText`, `stripVTControlCharacters`, `toUSVString`, `inherits`, `deprecate`, `callbackify`, `debuglog`, and `debug`. - `fs.existsSync`, `unlinkSync`, `chmodSync`, `chownSync`, `renameSync`, and `closeSync`, and `fs/promises.unlink`, `chmod`, and `rename`. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 00b72b071..62a816331 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -304,6 +304,7 @@ export const LIB_FN_SYMS: Record = { "navigator.languages": "scr_navigator_languages", "navigator.platform": "scr_navigator_platform", "process.builtinModule": "scr_process_builtin_module", + "process.builtinObject": "scr_process_builtin_object", "process.builtinUnsupported": "scr_process_builtin_unsupported", "module.namespaceValue": "scr_module_namespace_value", "module.namespaceDyn": "scr_module_namespace_dyn_value", diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index 2d9eb8964..1b037a28f 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -8,6 +8,7 @@ import { UNDEFINED_T, arrayOf, type IrExpr, + type IrFunction, type IrLibFn, type IrStmt, type IrType, @@ -16,6 +17,7 @@ import { typeKey, } from "../../ir/ir.js"; import { boolLit, dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; +import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; import { isJsSourceFile } from "../program.js"; import { funcTypeFromParamShapes, type ParamShape } from "./call-signatures.js"; import { keyedHelperName } from "./stable-names.js"; @@ -582,6 +584,27 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { type: { kind: "func", params: [STRING], ret: DYN }, loc, }; + // A path family is a plain object holding Node's members in Node's + // order, so reflection over it (Object.keys, Object.assign, spreads, + // own-property checks) sees them. + if (module.startsWith("path/")) { + return { + kind: "libCall", + fn: "process.builtinObject", + args: [ + strLit(module, loc), + box(getter), + { + kind: "arrayLit", + elems: PATH_MODULE_KEYS.map((key) => strLit(key, loc)), + type: arrayOf(STRING), + loc, + }, + ], + type: DYN, + loc, + }; + } return { kind: "libCall", fn: "process.builtinModule", @@ -591,6 +614,27 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { }; } +/** Object.keys of Node's path.posix and path.win32. */ +const PATH_MODULE_KEYS = [ + "resolve", + "normalize", + "isAbsolute", + "join", + "relative", + "toNamespacedPath", + "dirname", + "basename", + "extname", + "format", + "parse", + "matchesGlob", + "sep", + "delimiter", + "win32", + "posix", + "_makeLong", +]; + /** A first-class loader uses the same native functions as direct imports. * Module handles cache values and preserve aliases without a JS engine. */ export function lowerObjectAssignValue(lowerer: Lowerer, loc: SrcLoc): IrExpr { @@ -845,6 +889,108 @@ export function checkedNativeCallableValue( return { kind: "closure", fnName, captures: [], type: funcType, loc }; } +/** Every builtin function value that becomes untyped boxes as its checked + * twin, under the builtin's name and length, wherever the boxing happens: a + * module handle's member, an assertion's argument, or a read of a binding + * whose only stores are that builtin (`const join = path.join`): a const + * local, or a module-level binding, whose stores are all visible here. + * An untyped caller then holds the value that validates its arguments as + * Node does, and it compares equal to the same builtin read anywhere else. + * A binding read keeps its own evaluation, so its TDZ check still runs. */ +export function boxBuiltinValuesChecked(lowerer: Lowerer, functions: IrFunction[]): void { + const names = lowerer.builtinCallableValueNames; + if (names.size === 0) return; + const lengths = lowerer.builtinCallableValueLengths; + const builtin = (expr: IrExpr | null): expr is Extract => + expr !== null && + expr.kind === "closure" && + expr.captures.length === 0 && + (names.has(expr.fnName) || lowerer.builtinCheckedValues.has(expr.fnName)); + // Bindings and the one builtin closure they hold, or null when a binding + // stores anything else. Locals are keyed by their function. + const globals = new Set(lowerer.globalsList.map((g) => g.id)); + const bound = new Map | null>(); + const note = (key: string, value: IrExpr | null): void => { + const previous = bound.get(key); + const next = builtin(value) ? value : null; + bound.set( + key, + previous === undefined || (previous !== null && next?.fnName === previous.fnName) + ? next + : null, + ); + }; + const localKey = (fn: IrFunction, id: string): string => `${fn.name}\u0000${id}`; + for (const fn of functions) { + const constLocals = new Set(fn.locals.filter((l) => !l.mutable).map((l) => l.id)); + const keyOf = (id: string): string | null => + constLocals.has(id) ? localKey(fn, id) : globals.has(id) ? id : null; + // Any other node naming a binding (a for-of target, a catch binding, an + // expression-level write) may store something else in it. + const other = (node: object): void => { + for (const field of ["localId", "catchLocalId"]) { + const id = (node as Record)[field]; + const key = typeof id === "string" ? keyOf(id) : null; + if (key) note(key, null); + } + }; + everyStmtList(fn.body, { + expr: (expr) => { + if (expr.kind !== "varRef") other(expr); + return true; + }, + stmt: (stmt) => { + const key = stmt.kind === "varDecl" || stmt.kind === "assign" ? keyOf(stmt.localId) : null; + if (stmt.kind === "varDecl" && key) { + if (stmt.init) note(key, stmt.init); + } else if (stmt.kind === "assign" && key) note(key, stmt.value); + else other(stmt); + return true; + }, + }); + } + const lifted = lowerer.liftedFns.length; + for (const fn of functions) { + fn.body = transformStmtList(fn.body, { + stmt: (stmt) => stmt, + expr: (expr) => { + if (expr.kind !== "dynFrom") return expr; + const read = expr.value.kind === "varRef" ? expr.value : null; + const source = read + ? (bound.get(localKey(fn, read.localId)) ?? bound.get(read.localId) ?? null) + : builtin(expr.value) + ? expr.value + : null; + if (!source) return expr; + const value = lowerer.checkedBuiltinValue(source); + const fnName = + expr.fnName ?? + names.get(source.fnName) ?? + (value.kind === "closure" ? names.get(value.fnName) : undefined); + const fnLength = + expr.fnLength ?? + lengths.get(source.fnName) ?? + (value.kind === "closure" ? lengths.get(value.fnName) : undefined); + const boxed: IrExpr = { + ...expr, + value, + ...(fnName !== undefined ? { fnName } : {}), + ...(fnLength !== undefined ? { fnLength } : {}), + }; + if (!read) return boxed; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: read, loc: read.loc }], + result: boxed, + type: DYN, + loc: expr.loc, + }; + }, + }); + } + functions.push(...lowerer.liftedFns.slice(lifted)); +} + /** A JavaScript call of a builtin with named value parameters whose * arguments are not the native parameters' types or omit one * (`path.matchesGlob(123, glob)`, `path.toNamespacedPath()`): the call goes diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c56f6187e..4f795dca9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -179,6 +179,7 @@ import { } from "./coercions/unions.js"; import { assertedConversion } from "./coercions/asserted.js"; import { + boxBuiltinValuesChecked, lowerUtilTypeValue, checkedNativeCallableValue, pathModuleValue, @@ -3420,8 +3421,12 @@ export class Lowerer { /** Builtin function values whose `length` differs from their adapter's * parameter count (BuiltinModuleFn.valueLength), by adapter name. */ readonly builtinCallableValueLengths = new Map(); - /** Typed builtin adapters with a checked twin (checkedBuiltinValue). */ - readonly builtinCheckedValues = new Map IrExpr>(); + /** Typed builtin adapters with a checked twin (checkedBuiltinValue): the + * builtin, its display name and the location the twin is lowered at. */ + readonly builtinCheckedValues = new Map< + string, + { fn: BuiltinModuleFn; display: string; loc: SrcLoc } + >(); /** Optional-chain lowering state. While a chain body lowers, the guarded * receiver NODE reads as a chainRecv (typed by the narrowed arm) instead * of re-lowering, its checker type reads non-nullish (typeOf), and the @@ -4117,6 +4122,7 @@ export class Lowerer { mapTable("builtinCallableValueFns", this.builtinCallableValueFns), mapTable("builtinCallableValueNames", this.builtinCallableValueNames), countTable("builtinCallableValueLengths", this.builtinCallableValueLengths), + irDataTable("builtinCheckedValues", this.builtinCheckedValues), mapTable("regionRunHelpers", this.regionRunHelpers), mapTable("internTableHelpers", this.internTableHelpers), functionTable( @@ -9560,6 +9566,7 @@ export class Lowerer { /** Final retention, pruning, and module assembly shared by ordinary emit * and the retained reachability worklist. */ finishModule(functions: IrFunction[]): LowerResult { + boxBuiltinValuesChecked(this, functions); if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); @@ -17039,9 +17046,7 @@ export class Lowerer { }); } if (valueParams.some((param) => param.name !== undefined)) { - this.builtinCheckedValues.set(fnName, () => - checkedNativeCallableValue(this, fn, display, loc), - ); + this.builtinCheckedValues.set(fnName, { fn, display, loc }); } return { kind: "closure", fnName, captures: [], type: funcType, loc }; } @@ -17053,7 +17058,9 @@ export class Lowerer { checkedBuiltinValue(expr: IrExpr): IrExpr { const checked = expr.kind === "closure" ? this.builtinCheckedValues.get(expr.fnName) : undefined; - return checked ? checked() : expr; + return checked + ? checkedNativeCallableValue(this, checked.fn, checked.display, checked.loc) + : expr; } isBuiltinCallableValue(expr: IrExpr): boolean { diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index eef92c26b..515c48bf1 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2752,7 +2752,12 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] { id: "node-builtin.process.getBuiltinModule", kind: "node-builtin", name: "process.getBuiltinModule", - fns: ["process.builtinId", "process.builtinModule", "process.builtinUnsupported"], + fns: [ + "process.builtinId", + "process.builtinModule", + "process.builtinObject", + "process.builtinUnsupported", + ], note: "native path and os export subsets plus current-thread worker_threads metadata; other modules and exports throw SC2020", }, { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 6272b9e44..134556c3c 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -728,6 +728,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.keySetComputed", "process.builtinId", "process.builtinModule", + "process.builtinObject", "process.builtinUnsupported", "module.requireComputed", "module.resolveComputed", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ea87f5bc8..210d61e4e 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3641,6 +3641,7 @@ function buildLibFnSignatures() { "process.versions": { argTypes: [], result: DYN }, "process.builtinId": { argTypes: [DYN, arrayOf(STRING)], result: STRING }, "process.builtinModule": { argTypes: [STRING, DYN], result: DYN }, + "process.builtinObject": { argTypes: [STRING, DYN, arrayOf(STRING)], result: DYN }, "process.builtinUnsupported": { argTypes: [STRING, STRING], result: DYN }, /** A module namespace object as a checked-dynamic value * (scr_module_namespace_value): the module id, its sorted export names diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-module-values-untyped.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-module-values-untyped.cjs.json new file mode 100644 index 000000000..91c46efc0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-module-values-untyped.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/path-module-values-untyped.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 7c7893322..ace652f13 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -29,6 +29,7 @@ ScrDyn *scr_intl_segmenter_new(void); ScrDyn *scr_intl_segmenter_refusing(ScrStr *refusal); ScrStr *scr_process_builtin_id(ScrDyn *id, ScrArr *known); ScrDyn *scr_process_builtin_module(ScrStr *id, ScrDyn *getter); +ScrDyn *scr_process_builtin_object(ScrStr *id, ScrDyn *getter, ScrArr *keys); ScrDyn *scr_process_builtin_unsupported(ScrStr *id, ScrStr *member); /* A module namespace object as a checked-dynamic value (scr_module_namespace.c): * one handle per module `id`, whose reads call `getter` with the export diff --git a/packages/runtime/src/scr_node_builtin.c b/packages/runtime/src/scr_node_builtin.c index 37efa8822..71b523742 100644 --- a/packages/runtime/src/scr_node_builtin.c +++ b/packages/runtime/src/scr_node_builtin.c @@ -343,6 +343,8 @@ typedef struct ScrBuiltinModule { ScrDyn *getter; ScrDyn *exports; struct ScrBuiltinModule *next; + /* The exports object is the module value (scr_process_builtin_object). */ + bool object; } ScrBuiltinModule; static SCR_TL ScrBuiltinModule *scr_builtin_modules; @@ -364,6 +366,21 @@ static void scr_builtin_release(void *handle) { } static void scr_builtin_cleanup(void) { + /* An exports object can hold itself and the other path family + * (path.posix.posix, path.posix.win32): its member values are released + * first, so the objects are freed. The values are swapped out of the + * object's own storage, without reading its keys: the exit-time layout + * reset can run before this and free the layout the keys live in. */ + for (ScrBuiltinModule *module = scr_builtin_modules; module; module = module->next) { + if (!module->object) continue; + ScrDyn *exports = module->exports; + for (size_t i = 0; i < exports->v.obj.len; i++) { + ScrDyn **slot = exports->v.obj.layout ? &exports->v.obj.slots[i] : &exports->v.obj.entries[i].value; + ScrDyn *value = *slot; + *slot = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_release(value); + } + } while (scr_builtin_modules) { ScrBuiltinModule *module = scr_builtin_modules; scr_builtin_modules = module->next; @@ -439,11 +456,45 @@ ScrDyn *scr_process_builtin_module(ScrStr *id, ScrDyn *getter) { } ScrBuiltinModule *module = malloc(sizeof(*module)); if (!module) scr_trap("scriptc: out of memory\n"); - *module = (ScrBuiltinModule){ 1, scr_str_retain(id), scr_dyn_retain(getter), scr_dyn_new_obj(), scr_builtin_modules }; + *module = (ScrBuiltinModule){ 1, scr_str_retain(id), scr_dyn_retain(getter), scr_dyn_new_obj(), scr_builtin_modules, false }; scr_builtin_modules = module; return scr_dyn_new_handle(module, SCR_DYNH_BUILTIN_MODULE); } +/* A builtin module whose value is a plain object (the path families): every + * key in `keys`, in that order, holds the getter's answer, so Object.keys, + * Object.assign, spreads and own-property checks see Node's members. The + * object is built once per id and registered before its members are read, + * so a member naming the module itself or another family (path.posix.posix, + * path.posix.win32) answers the registered object. Borrows all three; +1. */ +ScrDyn *scr_process_builtin_object(ScrStr *id, ScrDyn *getter, ScrArr *keys) { + for (ScrBuiltinModule *module = scr_builtin_modules; module; module = module->next) { + if (module->id->len == id->len && memcmp(module->id->data, id->data, id->len) == 0) + return scr_dyn_retain(module->exports); + } + if (!scr_builtin_cleanup_registered) { + scr_atexit(scr_builtin_cleanup); + scr_builtin_cleanup_registered = true; + } + ScrBuiltinModule *module = malloc(sizeof(*module)); + if (!module) scr_trap("scriptc: out of memory\n"); + *module = (ScrBuiltinModule){ 1, scr_str_retain(id), scr_dyn_retain(getter), scr_dyn_new_obj(), scr_builtin_modules, true }; + scr_builtin_modules = module; + for (size_t i = 0; i < keys->len; i++) { + ScrStr *key = (ScrStr *)scr_arr_get_ref(keys, (double)i); + ScrDyn *arg = scr_dyn_new_str(key); + ScrDyn *value = scr_dyn_call(getter, &arg, 1, "builtin export"); + scr_dyn_release(arg); + if (!value) { + scr_str_release(key); + return NULL; + } + scr_dyn_obj_set(module->exports, key->data, key->len, value); + scr_str_release(key); + } + return scr_dyn_retain(module->exports); +} + ScrDyn *scr_process_builtin_unsupported(ScrStr *id, ScrStr *member) { ScrJsonBuf text; scr_jb_init(&text); diff --git a/tests/corpus/path-module-values-untyped.cjs b/tests/corpus/path-module-values-untyped.cjs new file mode 100644 index 000000000..41b8bab8f --- /dev/null +++ b/tests/corpus/path-module-values-untyped.cjs @@ -0,0 +1,82 @@ +// node:path module objects and function values held by untyped code. A +// path family passed through an untyped function is Node's plain object: +// Object.keys, Object.entries and Object.assign list its members in Node's +// order, and own-property checks see them. A function read through it is +// the same value as the typed read (`ns.join === path.posix.join`), and a +// const or module-level binding of a path function (`const join = +// path.join`) whose only store is that function, passed to untyped code, is +// that value too, so it compares equal and validates its arguments with +// Node's errors. +"use strict"; +const assert = require("assert"); +const path = require("path"); + +function pass(value) { + return value; +} + +// One value per function, whichever way it is read. +const ns = pass(path.posix); +assert.strictEqual(ns.join, path.posix.join); +assert.strictEqual(ns.join, path.join); +assert.strictEqual(pass(path).win32.basename, path.win32.basename); +assert.strictEqual(ns.posix, ns); +console.log(ns.join === path.posix.join, ns.join.name, ns.basename.name, ns.basename.length); + +// Reflection over the module objects. +function keys(o) { + return Object.keys(o); +} +function assign(o) { + return Object.assign({}, o); +} +console.log(keys(path.posix).join(",")); +console.log(keys(path.win32).join(",")); +console.log(keys(path).join(",") === keys(path.posix).join(",")); +const copy = assign(path.posix); +console.log(typeof copy.join, copy.join === path.posix.join, copy.sep, copy.delimiter, copy.win32 === path.win32); +const spread = pass({ ...pass(path.win32) }); +console.log(Object.keys(spread).length, spread.sep, spread.resolve === path.win32.resolve); +function strings(o) { + return Object.entries(o).filter(([, value]) => typeof value === "string"); +} +console.log(JSON.stringify(strings(path.posix)), JSON.stringify(strings(path.win32))); +function has(o, key) { + return Object.prototype.hasOwnProperty.call(o, key); +} +console.log(has(path.posix, "join"), has(path.win32, "_makeLong"), has(path.posix, "nope")); +let members = 0; +for (const key in pass(path.posix)) if (typeof path.posix[key] === "function") members++; +console.log(members); + +// Bindings of path functions passed to untyped code. +const join = path.join; +const basename = path.posix.basename; +let resolve = path.resolve; +function sameJoin(f) { + return f === path.join; +} +function sameBasename(f) { + return f === path.posix.basename; +} +function sameResolve(f) { + return f === path.resolve; +} +function call(f) { + return f("a", "b"); +} +console.log(sameJoin(join), sameBasename(basename), sameResolve(resolve), call(join)); +function local() { + const dirname = path.dirname; + return ((f) => f === path.dirname)(dirname); +} +console.log(local()); +function code(f) { + try { + f(5); + return "no throw"; + } catch (e) { + return `${e.name} ${e.code}`; + } +} +console.log(code(join), code(basename), code(resolve)); diff --git a/tests/harness/corpus-costs/path-module-values-untyped.cjs.json b/tests/harness/corpus-costs/path-module-values-untyped.cjs.json new file mode 100644 index 000000000..4345ae520 --- /dev/null +++ b/tests/harness/corpus-costs/path-module-values-untyped.cjs.json @@ -0,0 +1 @@ +{ "plain": 4655, "san": 2078 } From 6d170bf082c9b2c72c03cf42e7bd31871782f2e8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:36:13 -0500 Subject: [PATCH 050/193] Check every path namespace key now that format and parse have function values --- tests/corpus/path-namespace-spreads.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/corpus/path-namespace-spreads.js b/tests/corpus/path-namespace-spreads.js index c9931a451..e98d71b55 100644 --- a/tests/corpus/path-namespace-spreads.js +++ b/tests/corpus/path-namespace-spreads.js @@ -1,8 +1,7 @@ // Object spreads of node:path's namespaces copy the exports. The ES module // namespace has its keys sorted and also `default`, the module object; // posix, win32 and the default import are module objects, in Node's member -// order. The copies hold the module's own functions. The key lists leave out -// format and parse, which have no function values yet. +// order. The copies hold the module's own functions. import * as path from "node:path"; import defaultPath, { posix, win32 } from "node:path"; @@ -10,7 +9,7 @@ const fromNamespace = { ...path }; const fromDefault = { ...defaultPath }; const fromPosix = { ...posix }; const fromWin32 = { ...win32 }; -const keys = (copy) => JSON.stringify(Object.keys(copy).filter((key) => key !== "format" && key !== "parse")); +const keys = (copy) => JSON.stringify(Object.keys(copy)); console.log(keys(fromNamespace)); console.log(keys(fromPosix)); console.log(keys(fromDefault) === keys(fromPosix)); From 82fbe022b44fd472880e550f57c6db0dbe9a2ec5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:51:59 -0500 Subject: [PATCH 051/193] Deliver the lines a chunk split after a line listener closes the interface --- .../readline-close-in-line-listener.ts.json | 6 +++++ packages/runtime/src/scr_readline.c | 4 +++- .../corpus/readline-close-in-line-listener.ts | 23 +++++++++++++++++++ .../readline-close-in-line-listener.ts.json | 1 + 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-close-in-line-listener.ts.json create mode 100644 tests/corpus/readline-close-in-line-listener.ts create mode 100644 tests/harness/corpus-costs/readline-close-in-line-listener.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-close-in-line-listener.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-close-in-line-listener.ts.json new file mode 100644 index 000000000..0ea441125 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/readline-close-in-line-listener.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/readline-close-in-line-listener.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_readline.c b/packages/runtime/src/scr_readline.c index 8d0108649..672753e2b 100644 --- a/packages/runtime/src/scr_readline.c +++ b/packages/runtime/src/scr_readline.c @@ -339,8 +339,10 @@ static void scr_rl_feed_text(ScrRl *rl, const char *p, size_t n) { } rl->line_len = 0; if (start < n) scr_rl_buf_append(rl, p + start, n - start); + /* Node delivers every line the chunk split, also after a line listener + * closes the interface; an exception a listener throws ends the chunk. */ for (size_t i = 0; i < n_lines; i++) { - if (rl->closed || scr_exc_pending()) { + if (scr_exc_pending()) { scr_str_release(lines[i]); continue; } diff --git a/tests/corpus/readline-close-in-line-listener.ts b/tests/corpus/readline-close-in-line-listener.ts new file mode 100644 index 000000000..c86242cab --- /dev/null +++ b/tests/corpus/readline-close-in-line-listener.ts @@ -0,0 +1,23 @@ +// A 'line' listener that closes the interface: the 'close' event runs at +// once, the rest of the lines already split from the same chunk are still +// delivered, as in Node, and later chunks are not read. +import { createInterface } from "node:readline"; +import { Readable } from "node:stream"; + +const first = createInterface({ input: Readable.from(["x\ny\nz\n", "after\n"]) }); +first.on("line", (line: string) => { + console.log("line", line); + if (line === "y") first.close(); +}); +first.on("close", () => console.log("closed")); + +// Two listeners; the second closes on the first line of a chunk of three. +const second = createInterface({ input: Readable.from(["a\nb\nc"]) }); +const order: string[] = []; +second.on("line", (line: string) => order.push(`one:${line}`)); +second.on("line", (line: string) => { + order.push(`two:${line}`); + if (line === "a") second.close(); +}); +second.on("close", () => order.push("close")); +setTimeout(() => console.log(order.join(" ")), 20); diff --git a/tests/harness/corpus-costs/readline-close-in-line-listener.ts.json b/tests/harness/corpus-costs/readline-close-in-line-listener.ts.json new file mode 100644 index 000000000..5ac0e468a --- /dev/null +++ b/tests/harness/corpus-costs/readline-close-in-line-listener.ts.json @@ -0,0 +1 @@ +{ "plain": 4600, "san": 6500 } From 7f33f37384f62a9fd078cbd369b7eb8b61766175 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 03:58:21 -0500 Subject: [PATCH 052/193] Run for-in over a receiver that may be null or undefined for-in over a value whose type adds null or undefined to one enumerable type (a record, an index-signature record, an array) was refused with SC1052. JavaScript runs such a loop zero times for null and undefined. The loop now evaluates the receiver once, skips it when it is null or undefined, and otherwise walks the value as its own type. Fixes #2403 Co-authored-by: MathurAditya724 <57684218+MathurAditya724@users.noreply.github.com> --- .../src/frontend/lowering/lower-stmts.ts | 54 +++++++++++++++++++ .../corpus/for-in-nullable-receiver.ts.json | 6 +++ tests/corpus/for-in-nullable-receiver.ts | 48 +++++++++++++++++ .../for-in-nullable-receiver.ts.json | 1 + 4 files changed, 109 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/for-in-nullable-receiver.ts.json create mode 100644 tests/corpus/for-in-nullable-receiver.ts create mode 100644 tests/harness/corpus-costs/for-in-nullable-receiver.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 377b08917..9a02a6477 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -12983,7 +12983,61 @@ function lowerForIn(lowerer: Lowerer, stmt: ts.ForInStatement): IrStmt { receiver = lowerer.coerceToExpected(receiver, DYN); } } + return lowerForInReceiver(lowerer, stmt, labels, receiver); +} + +/** for-in over the lowered `receiver` of `stmt`. */ +function lowerForInReceiver( + lowerer: Lowerer, + stmt: ts.ForInStatement, + labels: string[] | undefined, + receiver: IrExpr, +): IrStmt { + const loc = locOf(stmt); const recvT = receiver.type; + // A receiver that may be null or undefined: for-in visits nothing for + // either, and walks a present value as its own type. The receiver is + // evaluated once. + if (recvT.kind === "union") { + const arms = lowerer.unions.get(recvT.unionId)?.arms ?? []; + const present = arms.filter((arm) => arm.kind !== "undefinedT" && arm.kind !== "nullT"); + if (present.length === 1 && present.length < arms.length) { + const arm = present[0]!; + const tag = lowerer.armTag(recvT.unionId, arm); + const recv = lowerer.declareHiddenLocal("%innullable", recvT); + const ref = varRef(recv.id, recvT, loc); + const narrowed: IrExpr = { + kind: "unionNarrow", + unionId: recvT.unionId, + tag, + value: ref, + type: arm, + loc, + }; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: recv.id, init: receiver, loc }, + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: recvT.unionId, + tag, + negated: false, + value: ref, + type: BOOL, + loc, + }, + then: [lowerForInReceiver(lowerer, stmt, labels, narrowed)], + else_: null, + loc, + }, + ], + loc, + }; + } + } if (recvT.kind === "dyn") { const recv = lowerer.declareHiddenLocal("%indyn", DYN); const ref: IrExpr = { kind: "varRef", localId: recv.id, type: DYN, loc }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/for-in-nullable-receiver.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/for-in-nullable-receiver.ts.json new file mode 100644 index 000000000..1f5d3afc6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/for-in-nullable-receiver.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/for-in-nullable-receiver.ts" + ], + "diags": [] +} diff --git a/tests/corpus/for-in-nullable-receiver.ts b/tests/corpus/for-in-nullable-receiver.ts new file mode 100644 index 000000000..d1e6c4d53 --- /dev/null +++ b/tests/corpus/for-in-nullable-receiver.ts @@ -0,0 +1,48 @@ +// for-in over a value that may be null or undefined runs zero times for +// either and walks a present record, array or index-signature record. The +// receiver is evaluated once. +function keys(value: { a: number } | null | undefined): string { + let result = ""; + for (const key in value) result += key; + return result; +} +console.log("[" + keys(null) + "] [" + keys(undefined) + "] [" + keys({ a: 1 }) + "]"); + +function maybeNull(value: { a: number; b: string } | null): string[] { + const out: string[] = []; + for (const key in value) out.push(key); + return out; +} +console.log(maybeNull(null), maybeNull({ a: 1, b: "x" })); + +function indexes(values: string[] | undefined): string { + let result = ""; + for (const index in values) result += index; + return result; +} +console.log(indexes(undefined), indexes(["p", "q", "r"])); + +function dictionary(map: Record | undefined): string { + const seen: string[] = []; + for (const key in map) seen.push(`${key}=${map![key]}`); + return seen.join(","); +} +console.log(dictionary(undefined), dictionary({ x: 1, y: 2 })); + +let reads = 0; +function source(present: boolean): { a: number; b: number } | undefined { + reads++; + return present ? { a: 1, b: 2 } : undefined; +} +const visited: string[] = []; +for (const key in source(true)) visited.push(key); +for (const key in source(false)) visited.push(key); +console.log(visited, reads); + +outer: for (const round of [1, 2]) { + for (const key in round === 1 ? { a: 1, b: 2, c: 3 } : null) { + if (key === "b") continue outer; + console.log(round, key); + } + console.log("after", round); +} diff --git a/tests/harness/corpus-costs/for-in-nullable-receiver.ts.json b/tests/harness/corpus-costs/for-in-nullable-receiver.ts.json new file mode 100644 index 000000000..1cfe56769 --- /dev/null +++ b/tests/harness/corpus-costs/for-in-nullable-receiver.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 3200 } From eff8ef743db680848b6490d63093a50b7654cffe Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:11:04 -0500 Subject: [PATCH 053/193] Read at the exact bigint position in fs.readSync Co-authored-by: MathurAditya724 <57684218+MathurAditya724@users.noreply.github.com> Fixes #2404 --- .../corpus/fs-read-bigint-position.cjs.json | 6 +++ .../fs-read-sync-bigint-position.cjs.json | 6 +++ packages/runtime/src/scr_bytes_io.c | 41 +++++++++++++++---- packages/runtime/src/scr_file_handle.h | 6 +++ packages/runtime/src/scr_lib.c | 41 ++++++++++++++----- tests/corpus/fs-read-bigint-position.cjs | 28 +++++++++++++ tests/corpus/fs-read-sync-bigint-position.cjs | 32 +++++++++++++++ .../fs-read-bigint-position.cjs.json | 1 + .../fs-read-sync-bigint-position.cjs.json | 1 + 9 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-bigint-position.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-sync-bigint-position.cjs.json create mode 100644 tests/corpus/fs-read-bigint-position.cjs create mode 100644 tests/corpus/fs-read-sync-bigint-position.cjs create mode 100644 tests/harness/corpus-costs/fs-read-bigint-position.cjs.json create mode 100644 tests/harness/corpus-costs/fs-read-sync-bigint-position.cjs.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-bigint-position.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-bigint-position.cjs.json new file mode 100644 index 000000000..24039a8ab --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-bigint-position.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-read-bigint-position.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-sync-bigint-position.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-sync-bigint-position.cjs.json new file mode 100644 index 000000000..4d4997e89 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-read-sync-bigint-position.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-read-sync-bigint-position.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 0c4bee4ee..035795619 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -166,6 +166,8 @@ static ScrDyn *scr_fs_cb_arg(ScrDyn *const *args, size_t argc, size_t index) { return index < argc ? args[index] : scr_dyn_undefined(); } +static bool scr_fs_bigint_position_chk(ScrBigInt *position, double length); + static ScrDyn *scr_fs_cb_option(const ScrDyn *options, const char *name) { if (scr_fs_dyn_absent(options)) return scr_dyn_retain(scr_dyn_undefined()); ScrDyn *object = options->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(options) : scr_dyn_retain((ScrDyn *)options); @@ -1129,8 +1131,30 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc if (ownedBuffer) length = scr_fs_cb_option_number(options, "length", length); else if (readOp && count >= 4) length = readLength; else length = scr_fs_cb_number(ARG(3), "length", length); - double position = ownedBuffer ? scr_fs_cb_option_number(options, "position", -1) : scr_fs_cb_number(ARG(4), "position", -1); - if (!scr_exc_pending()) result = scr_dyn_new_num(readOp ? scr_fs_read_sync(fd, bytes, offset, length, position) : scr_fs_write_sync(fd, bytes, offset, length, position)); + /* A bigint read position takes readSync's range check and reads at + * its exact value: a double would round it past 2^53. */ + ScrDyn *bigPosition = NULL; + if (readOp) { + ScrDyn *value = ownedBuffer ? scr_fs_cb_option(options, "position") + : scr_dyn_retain(count > 4 ? ARG(4) : scr_dyn_undefined()); + if (value && value->kind == SCR_DYN_BIGINT) bigPosition = value; + else scr_dyn_release(value); + } + double position = bigPosition ? -1 + : ownedBuffer ? scr_fs_cb_option_number(options, "position", -1) + : scr_fs_cb_number(ARG(4), "position", -1); + if (!scr_exc_pending()) { + if (bigPosition) { + if (scr_fs_read_offset_chk(offset) && + scr_fs_bigint_position_chk(bigPosition->v.bigint, length)) + result = scr_dyn_new_num(scr_fs_read_sync_at(fd, bytes, offset, length, + scr_bigint_to_i64(bigPosition->v.bigint))); + } else { + result = scr_dyn_new_num(readOp ? scr_fs_read_sync(fd, bytes, offset, length, position) + : scr_fs_write_sync(fd, bytes, offset, length, position)); + } + } + scr_dyn_release(bigPosition); } scr_bytes_release(bytes); /* The completion retains the original buffer, including options.buffer. */ @@ -2101,12 +2125,15 @@ double scr_fs_read_sync_dyn(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn double count = 0; if (!scr_fs_read_validate(fd, buffer, offset, length, position, &count)) return 0; if (count == 0) return 0; - double at = scr_fs_dyn_absent(position) ? -1 - : position->kind == SCR_DYN_BIGINT ? scr_bigint_to_f64(position->v.bigint) - : position->v.num; + double off = scr_fs_dyn_absent(offset) ? 0 : offset->v.num; ScrBytes *bytes = scr_dyn_bytes_unbox(buffer); - double read = scr_fs_read_sync(fd->v.num, bytes, scr_fs_dyn_absent(offset) ? 0 : offset->v.num, - count, at); + /* A bigint position passed its range check above and reads at its exact + * value: a double would round it past 2^53. */ + double read = + !scr_fs_dyn_absent(position) && position->kind == SCR_DYN_BIGINT + ? scr_fs_read_sync_at(fd->v.num, bytes, off, count, scr_bigint_to_i64(position->v.bigint)) + : scr_fs_read_sync(fd->v.num, bytes, off, count, + scr_fs_dyn_absent(position) ? -1 : position->v.num); scr_bytes_release(bytes); return read; } diff --git a/packages/runtime/src/scr_file_handle.h b/packages/runtime/src/scr_file_handle.h index 5bdd15a65..4d038ffbe 100644 --- a/packages/runtime/src/scr_file_handle.h +++ b/packages/runtime/src/scr_file_handle.h @@ -60,6 +60,12 @@ ScrStats *scr_file_handle_stat(ScrFileHandle *h); * nonnegative positions leave that offset unchanged (pread/ReadFile seam). */ double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, double position); +/* fs.readSync's offset check; false with ERR_OUT_OF_RANGE pending. */ +bool scr_fs_read_offset_chk(double offset); +/* scr_fs_read_sync after its offset and position checks, with an exact + * position (-1 for the current offset). */ +double scr_fs_read_sync_at(double fd, ScrBytes *buf, double offset, double length, + int64_t position); /* writeSync's classic Buffer window and utf8 string forms. position -1 (or * any other non-safe/nonnegative-integer value) writes at and advances the * current descriptor offset; safe nonnegative positions leave it unchanged. */ diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9ef71a223..a676b09f1 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -3935,7 +3935,7 @@ void scr_fs_futimes(double fd, double atime, double mtime) { * supplies pread(2). Windows follows libuv's synchronous-handle recipe: * ReadFile with an OVERLAPPED byte offset, then restore the handle position * because synchronous ReadFile updates it even when OVERLAPPED is present. */ -static ssize_t scr_fs_pread(int fd, void *data, size_t length, double position) { +static ssize_t scr_fs_pread(int fd, void *data, size_t length, int64_t position) { #ifdef _WIN32 HANDLE handle = (HANDLE)_get_osfhandle(fd); if (handle == INVALID_HANDLE_VALUE) { @@ -4311,14 +4311,11 @@ void scr_fs_write_file_fd(double fd, ScrStr *data) { * throw the RangeError shape. Position -1 means the fd's current offset; * nonnegative positions do not advance it. Returns the byte count the OS * reports; errors carry the errno name like the other fd operations. */ -double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, - double position) { - size_t bytelen = buf->len; /* u8 buffers: elem count == byte count */ +/* fs.readSync's offset check: an integer in [0, 2^53 - 1], Node's exact + * texts (validateOffset); false with ERR_OUT_OF_RANGE pending otherwise. */ +bool scr_fs_read_offset_chk(double offset) { char msg[160]; int mlen; - /* Node's exact texts: offset validates against MAX_SAFE_INTEGER's range - * form, length against the remaining window (validateOffset vs the - * buffer-bounds check in fs.readSync). */ char numbuf[40]; if (!(isfinite(offset) && trunc(offset) == offset)) { char recv[48]; @@ -4327,7 +4324,7 @@ double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, "The value of \"offset\" is out of range. It must be an integer. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)mlen, "ERR_OUT_OF_RANGE"); - return 0; + return false; } if (offset < 0 || offset > 9007199254740991.0) { numbuf[scr_f64_to_str(offset, numbuf)] = 0; @@ -4335,8 +4332,19 @@ double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, "The value of \"offset\" is out of range. It must be >= 0 && <= 9007199254740991. Received %s", numbuf); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)mlen, "ERR_OUT_OF_RANGE"); - return 0; + return false; } + return true; +} + +double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, + double position) { + char msg[160]; + int mlen; + /* Node's exact texts: offset validates against MAX_SAFE_INTEGER's range + * form, length against the remaining window (validateOffset vs the + * buffer-bounds check in fs.readSync). */ + if (!scr_fs_read_offset_chk(offset)) return 0; if (!(isfinite(position) && trunc(position) == position)) { char recv[48]; scr_num_received(position, recv); @@ -4355,6 +4363,16 @@ double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)mlen, "ERR_OUT_OF_RANGE"); return 0; } + return scr_fs_read_sync_at(fd, buf, offset, length, (int64_t)position); +} + +/* fs.readSync after its offset and position checks: `position` is exact, + * so a bigint position past 2^53 reads where it says. */ +double scr_fs_read_sync_at(double fd, ScrBytes *buf, double offset, double length, + int64_t position) { + size_t bytelen = buf->len; /* u8 buffers: elem count == byte count */ + char msg[160]; + int mlen; /* `length |= 0`, then Node's order: a zero length reads nothing after * the offset and position checks; otherwise the buffer must not be * empty and the window must fit it (validateOffsetLengthRead). */ @@ -4417,7 +4435,7 @@ double scr_fs_read_zero(double fd, double position) { if (!scr_fs_write_fd_valid(fd)) return 0; uint8_t dummy; ssize_t n = position < 0 ? read((int)fd, &dummy, 0) - : scr_fs_pread((int)fd, &dummy, 0, position); + : scr_fs_pread((int)fd, &dummy, 0, (int64_t)position); if (n < 0) scr_fs_throw_nopath(errno, "read"); return 0; } @@ -4498,7 +4516,8 @@ int scr_fs_raw_open(const char *path, int flags, int mode) { } int64_t scr_fs_raw_read(int fd, void *data, size_t length, double position) { - ssize_t n = position >= 0 ? scr_fs_pread(fd, data, length, position) : read(fd, data, length); + ssize_t n = position >= 0 ? scr_fs_pread(fd, data, length, (int64_t)position) + : read(fd, data, length); return n < 0 ? -errno : n; } diff --git a/tests/corpus/fs-read-bigint-position.cjs b/tests/corpus/fs-read-bigint-position.cjs new file mode 100644 index 000000000..b54f331cf --- /dev/null +++ b/tests/corpus/fs-read-bigint-position.cjs @@ -0,0 +1,28 @@ +// fs.read's callback forms with bigint positions read at the exact +// position, including past 2^53 (beyond the file's end, so nothing is +// read), and -1n reads at the current offset. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-read-cb-position-")); +const file = path.join(dir, "data"); +fs.writeFileSync(file, "abc"); +const fd = fs.openSync(file, "r"); +const big = 9007199254740993n; + +function step(label, run, next) { + run((error, bytesRead, buffer) => { + console.log(label, error ? error.code : null, bytesRead, buffer ? buffer.toString("latin1", 0, bytesRead) : ""); + next(); + }); +} + +step("positional", (cb) => fs.read(fd, Buffer.alloc(1), 0, 1, big, cb), () => + step("options", (cb) => fs.read(fd, { buffer: Buffer.alloc(1), position: big }, cb), () => + step("buffer and options", (cb) => fs.read(fd, Buffer.alloc(2), { position: big }, cb), () => + step("small", (cb) => fs.read(fd, Buffer.alloc(2), 0, 2, 1n, cb), () => + step("current", (cb) => fs.read(fd, Buffer.alloc(1), 0, 1, -1n, cb), () => { + fs.closeSync(fd); + fs.rmSync(dir, { recursive: true }); + }))))); diff --git a/tests/corpus/fs-read-sync-bigint-position.cjs b/tests/corpus/fs-read-sync-bigint-position.cjs new file mode 100644 index 000000000..4b3a36c22 --- /dev/null +++ b/tests/corpus/fs-read-sync-bigint-position.cjs @@ -0,0 +1,32 @@ +// fs.readSync with bigint positions reads at the exact position, including +// past 2^53 (beyond a one-byte file's end, so it reads nothing), leaves the +// descriptor's offset unchanged, and -1n reads at the current offset. +// Positions outside [-1n, 2n ** 63n - 1n - length] throw ERR_OUT_OF_RANGE. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-read-position-")); +const file = path.join(dir, "data"); +fs.writeFileSync(file, "xyz"); +const fd = fs.openSync(file, "r"); +const buffer = Buffer.alloc(2); +try { + console.log(fs.readSync(fd, buffer, 0, 1, 0n), buffer.toString("latin1", 0, 1)); + console.log(fs.readSync(fd, buffer, 0, 2, 1n), buffer.toString("latin1")); + console.log(fs.readSync(fd, buffer, 0, 1, 9007199254740993n)); + console.log(fs.readSync(fd, buffer, 0, 1, 9223372036854775806n)); + // The positioned reads above left the current offset at 0. + console.log(fs.readSync(fd, buffer, 0, 1, -1n), buffer.toString("latin1", 0, 1)); + console.log(fs.readSync(fd, buffer, 0, 1, -1n), buffer.toString("latin1", 0, 1)); + for (const position of [-2n, 9223372036854775807n]) { + try { + fs.readSync(fd, buffer, 0, 1, position); + } catch (error) { + console.log(error.code, error.message); + } + } +} finally { + fs.closeSync(fd); + fs.rmSync(dir, { recursive: true }); +} diff --git a/tests/harness/corpus-costs/fs-read-bigint-position.cjs.json b/tests/harness/corpus-costs/fs-read-bigint-position.cjs.json new file mode 100644 index 000000000..9c76a3418 --- /dev/null +++ b/tests/harness/corpus-costs/fs-read-bigint-position.cjs.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 4500 } diff --git a/tests/harness/corpus-costs/fs-read-sync-bigint-position.cjs.json b/tests/harness/corpus-costs/fs-read-sync-bigint-position.cjs.json new file mode 100644 index 000000000..9c76a3418 --- /dev/null +++ b/tests/harness/corpus-costs/fs-read-sync-bigint-position.cjs.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 4500 } From 7e898a39ba740d61ca6e0299770c756365396629 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:11:38 -0500 Subject: [PATCH 054/193] Record the ts7 order baseline of os-userinfo-passwd-entry.ts without diagnostics The program has no preflight diagnostics; the recorded baseline listed an SC0001 for NodeJS.ProcessEnv that the 5.9.3 preflight does not report. --- .../tests/corpus/os-userinfo-passwd-entry.ts.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json index 8d287746c..5b8821ab2 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/os-userinfo-passwd-entry.ts.json @@ -2,15 +2,5 @@ "order": [ "/tests/corpus/os-userinfo-passwd-entry.ts" ], - "diags": [ - { - "code": "SC0001", - "message": "Namespace 'NodeJS' has no exported member 'ProcessEnv'.", - "loc": { - "file": "/tests/corpus/os-userinfo-passwd-entry.ts", - "start": 1578, - "end": 1588 - } - } - ] + "diags": [] } From 2fee369fe99039e2b3fe3c37f053b52f3655f2ed Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:14:27 -0500 Subject: [PATCH 055/193] Split the Linux LLVM artifacts job and take its glibc packages from native_linux --- .github/workflows/ci.yml | 117 ++++++++++++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23936e46a..5c8c624b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -686,11 +686,17 @@ jobs: run: pnpm test:checker-identity --optimization dev llvm_artifacts_linux_x64: - needs: [validation_scope, lanes] + needs: [validation_scope, lanes, native_linux] if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }} name: test (Linux x64 LLVM artifacts, no clang) runs-on: ubuntu-24.04 - timeout-minutes: 30 + # The glibc helper and runtime pack come from native_linux, as the macOS + # artifact jobs take theirs from native_macos. The musl packages and the + # WASI smoke run in llvm_artifacts_linux_x64_musl and + # llvm_artifacts_linux_x64_wasi: together with a rebuild of the glibc + # packages, which a runtime change forces, the one job took 25 to 32 + # minutes. + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -700,32 +706,13 @@ jobs: with: node-version-file: .node-version cache: pnpm - - uses: ./.github/actions/llvm-linux - run: pnpm install --frozen-lockfile - uses: ./.github/actions/zig - - uses: ./.github/actions/native-packages + - uses: actions/download-artifact@v4 with: - target: linux-x64-gnu - - name: Build and verify Linux x64 musl release packages - run: | - pnpm --filter @scriptc/llvm-linux-x64-musl build:native - CC=zig AR=zig pnpm --filter @scriptc/runtime-linux-x64-musl build:native - helper=packages/llvm-linux-x64-musl/bin/scriptc-llvm-codegen - readelf -l "$helper" > "$RUNNER_TEMP/musl-helper-program-headers" - if grep -q INTERP "$RUNNER_TEMP/musl-helper-program-headers"; then - echo "musl helper must be a static executable" >&2 - exit 1 - fi - docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" alpine:3.22 \ - /work/$helper version --format=json | node -e ' - const value = JSON.parse(require("node:fs").readFileSync(0, "utf8")); - if (value.default_target !== "x86_64-unknown-linux-musl" || !value.supported_targets?.includes("wasm32-unknown-wasi")) process.exit(1); - ' - node packages/runtime-linux-x64-musl/scripts/verify.mjs - - uses: ./.github/actions/native-packages - with: - target: wasm32-wasi - helper: "false" + name: native-linux-x64-gnu + path: ${{ runner.temp }} + - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" - run: pnpm build - name: Linux helper object format and no-clang output contract run: | @@ -751,6 +738,82 @@ jobs: SCRIPTC_NO_CACHE=1 SCRIPTC_LEGACY_C_PIPELINE=0 node packages/cli/dist/main.js build tests/corpus/001-hello.ts -o /tmp/hello test "$(/tmp/hello)" = "hello world" ' + + llvm_artifacts_linux_x64_musl: + needs: [validation_scope, lanes, native_linux] + if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }} + name: test (Linux x64 LLVM artifacts, no clang, musl) + runs-on: ubuntu-24.04 + # The static musl helper and runtime pack, restored from the + # native-packages cache when their inputs are unchanged: about 9 minutes + # when both are built. The runtime build emits its units through the + # host's glibc helper (runtime-unit.mjs), which comes from native_linux. + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - uses: ./.github/actions/llvm-linux + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/zig + - uses: actions/download-artifact@v4 + with: + name: native-linux-x64-gnu + path: ${{ runner.temp }} + - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" + - uses: ./.github/actions/native-packages + with: + target: linux-x64-musl + - name: Verify the Linux x64 musl release helper + run: | + helper=packages/llvm-linux-x64-musl/bin/scriptc-llvm-codegen + readelf -l "$helper" > "$RUNNER_TEMP/musl-helper-program-headers" + if grep -q INTERP "$RUNNER_TEMP/musl-helper-program-headers"; then + echo "musl helper must be a static executable" >&2 + exit 1 + fi + docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" alpine:3.22 \ + /work/$helper version --format=json | node -e ' + const value = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + if (value.default_target !== "x86_64-unknown-linux-musl" || !value.supported_targets?.includes("wasm32-unknown-wasi")) process.exit(1); + ' + + llvm_artifacts_linux_x64_wasi: + needs: [validation_scope, lanes, native_linux] + if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }} + name: test (Linux x64 LLVM artifacts, no clang, wasm32) + runs-on: ubuntu-24.04 + # The wasm32-wasi runtime pack (restored from the native-packages cache + # when unchanged; its units go through native_linux's glibc helper) and + # the WASI smoke tests, which take 6 to 9 minutes. + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - uses: ./.github/actions/llvm-linux + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/zig + - uses: actions/download-artifact@v4 + with: + name: native-linux-x64-gnu + path: ${{ runner.temp }} + - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" + - uses: ./.github/actions/native-packages + with: + target: wasm32-wasi + helper: "false" + - run: pnpm build - name: WASI helper object/runtime-pack smoke run: pnpm test packages/cli/test/wasi-runtime-pack.test.ts tests/harness/wasm-library.test.ts @@ -1120,6 +1183,8 @@ jobs: ts7_parity, checker_identity, llvm_artifacts_linux_x64, + llvm_artifacts_linux_x64_musl, + llvm_artifacts_linux_x64_wasi, llvm_artifacts_macos_arm64, llvm_artifacts_macos_arm64_contracts, llvm_artifacts_macos_x64, @@ -1154,6 +1219,8 @@ jobs: lane ts7_parity "${{ needs.ts7_parity.result }}" "${{ needs.lanes.outputs.linux }}" lane checker_identity "${{ needs.checker_identity.result }}" "${{ needs.lanes.outputs.linux }}" lane llvm_artifacts_linux_x64 "${{ needs.llvm_artifacts_linux_x64.result }}" "${{ needs.lanes.outputs.linux }}" + lane llvm_artifacts_linux_x64_musl "${{ needs.llvm_artifacts_linux_x64_musl.result }}" "${{ needs.lanes.outputs.linux }}" + lane llvm_artifacts_linux_x64_wasi "${{ needs.llvm_artifacts_linux_x64_wasi.result }}" "${{ needs.lanes.outputs.linux }}" lane llvm_artifacts_macos_arm64 "${{ needs.llvm_artifacts_macos_arm64.result }}" "${{ needs.lanes.outputs.macos }}" lane llvm_artifacts_macos_arm64_contracts "${{ needs.llvm_artifacts_macos_arm64_contracts.result }}" "${{ needs.lanes.outputs.macos }}" lane llvm_artifacts_macos_x64 "${{ needs.llvm_artifacts_macos_x64.result }}" "${{ needs.lanes.outputs.macos }}" From c533750aa3ab7bc1df9216264333cfec141c2f09 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:17:06 -0500 Subject: [PATCH 056/193] Test a jammed loop's exit every iteration when its counter may not advance The jammed loop tests its exit only when `(j & 3) === 3`. When the inner counter starts at NaN, an infinity, or a value that `j + 1` leaves unchanged, `j & 3` never becomes 3, and the loop ran past the point where every accumulator was full, without end when its bound never ends it. The every-fourth test is now used only when the inner loop starts at an int32 literal: the counter then takes consecutive integer values, so its low bits reach 3 within four iterations. From any other start, the jammed loop tests its exit every iteration. The unit test checks which starts get each test, and the corpus program adds loops starting at -Infinity and -(2 ** 54) that end once both pixels escape. --- .../src/ir/saturating-loop-jams.test.ts | 21 +++++++++++++++ .../compiler/src/ir/saturating-loop-jams.ts | 22 ++++++++++----- tests/corpus/saturating-loop-jams.ts | 27 +++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts index 25320b7a6..7593af5a8 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.test.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -187,6 +187,27 @@ test("jams a constant-count loop whose inner loop exits once its accumulator is expect(posts.every((s) => s.kind === "assign" && s.localId === "res")).toBe(true); }); +test("tests the exit every iteration unless the inner loop starts at an int32 literal", () => { + const exitTest = (innerStart: IrExpr): IrExpr => { + const out = jamSaturatingLoops(moduleOf(nest({ innerStart }))); + const inner = loops(out.functions[0]!.body)[0]!; + const last = inner.body[inner.body.length - 1]!; + if (last.kind !== "if") throw new Error("expected an exit test"); + return last.cond; + }; + const everyFourth = (cond: IrExpr): boolean => { + if (cond.kind !== "logical" || cond.left.kind !== "bin") return false; + const low = cond.left.left; + return low.kind === "bin" && low.op === "&"; + }; + expect(everyFourth(exitTest(num(-5)))).toBe(true); + expect(everyFourth(exitTest(num(0x7fffffff)))).toBe(true); + // `j` may be NaN, an infinity or a value `j + 1` leaves unchanged. + expect(everyFourth(exitTest(ref("start")))).toBe(false); + expect(everyFourth(exitTest(num(2 ** 31)))).toBe(false); + expect(everyFourth(exitTest(num(-(2 ** 31) - 1)))).toBe(false); +}); + test("keeps loops whose exits or values do not allow running iterations together", () => { const reject = (body: IrStmt[]): void => { const mod = moduleOf(body); diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts index 74c204fea..3773f713f 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -332,8 +332,9 @@ function matchJam( * overlap their dependence chains. * * For the same reason the jammed loop may run past the iteration where every - * accumulator is full: it tests that every fourth iteration (`j & 3` is 3) - * and keeps the inner loop's own bound. The copies' statements alternate, + * accumulator is full: when the inner loop starts at an int32 literal, it + * tests that every fourth iteration (`j & 3` is 3), and it keeps the inner + * loop's own bound. The copies' statements alternate, * and each local's copies are added next to each other, so the vectorizer * packs one copy per lane. */ export function jamSaturatingLoops(mod: IrModule): IrModule { @@ -382,7 +383,8 @@ function emitJam( const { inner, pre, post, values, counter } = jam; const loc = inner.loc; const own = declared([...pre, ...inner.body]); - const j = (inner.init as IrStmt & { kind: "varDecl" }).localId; + const init = inner.init as IrStmt & { kind: "varDecl" }; + const j = init.localId; own.delete(j); // Each local's copies are added next to each other: the vectorizer pairs // neighboring values of the loop's header, so one copy per lane. @@ -449,15 +451,23 @@ function emitJam( }; // `(j & 3) === 3` const fourth: IrExpr = { kind: "bin", op: "===", left: counter3, right: three, type: BOOL, loc }; + // `j` reaches a value whose low bits are 3 within four iterations when it + // starts at an int32 literal: it then takes consecutive integer values, + // which stay exact for more than 2^52 iterations. From another start (NaN, + // an infinity, a value where `j + 1` is `j`), the exit is tested every + // iteration. + const start = intLit(init.init); + const everyFourth = start !== null && start >= -0x80000000 && start <= 0x7fffffff; const jammed: IrStmt = { ...inner, body: [ - // The copies take turns statement by statement; the exit is tested - // every fourth iteration. + // The copies take turns statement by statement. ...copies[0]!.body.flatMap((_, k) => copies.map((copy) => copy.body[k]!)), { kind: "if", - cond: { kind: "logical", op: "&&", left: fourth, right: allFull!, type: BOOL, loc }, + cond: everyFourth + ? { kind: "logical", op: "&&", left: fourth, right: allFull!, type: BOOL, loc } + : allFull!, then: [{ kind: "break", loc }], else_: null, loc, diff --git a/tests/corpus/saturating-loop-jams.ts b/tests/corpus/saturating-loop-jams.ts index fb0fd5bb3..d4fd5e94d 100644 --- a/tests/corpus/saturating-loop-jams.ts +++ b/tests/corpus/saturating-loop-jams.ts @@ -142,6 +142,31 @@ function shifted(initial: Float64Array): number { return res; } +// The inner counter starts where `j++` leaves it unchanged or where its +// low bits never reach 3: the loop ends once both pixels escape. +function stuck(initial: Float64Array, start: number): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + for (let j = start; j < 40; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + } + res = res * 4 + b; + } + return res; +} + const size = 64; const initial = new Float64Array(size); for (let x = 0; x < size; x++) initial[x] = (2 * x) / size - 1.5; @@ -162,3 +187,5 @@ console.log(row(initial, 0.5, (size >> 3) + 1, 50).slice(-2)); const r = new Float64Array([1.2, 0.5, 1.3, 0.9]); const s = new Float64Array([0.1, 0.1, 0.73, 0.74]); console.log(resets(q), resets(r), skips(q), skips(s), shifted(q), shifted(r)); +const e = new Float64Array([1.2, 1.5, 3, 0.9]); +console.log(stuck(e, -Infinity), stuck(e, -(2 ** 54)), stuck(e, 0.5), stuck(e, -7)); From 442c06fde3982b50df6c9b8127e8c465fe73cfd7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:20:01 -0500 Subject: [PATCH 057/193] Throw Node's TypeError when a nullable destructuring source is null In JavaScript files, a destructuring declaration whose source is one object or array type or null/undefined (`text.match(re)`, `items.find(...)`) threw a plain Error when the source was null or undefined. It now throws Node's TypeError with V8's message: "Cannot destructure property 'index' of 'text.match(...)' as it is null." for an object pattern, and "found is not iterable" or "object null is not iterable (cannot read property Symbol(Symbol.iterator))" for an array pattern. An object pattern whose source V8 spells in a form the compiler does not reproduce keeps the runtime fence. --- .../src/frontend/lowering/lower-stmts.ts | 87 +++++++++++++------ .../destructure-nullable-union-source.js.json | 6 ++ .../destructure-nullable-union-source.js | 63 ++++++++++++++ .../destructure-nullable-union-source.js.json | 1 + 4 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json create mode 100644 tests/corpus/destructure-nullable-union-source.js create mode 100644 tests/harness/corpus-costs/destructure-nullable-union-source.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 377b08917..05a8cfa2d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2549,13 +2549,26 @@ export function lowerDestructuringDecl( } } const out: IrStmt[] = []; + // V8's destructuring TypeError spells STATIC sources: an identifier + // source reads " is not iterable", an identifier-callee call + // " is not a function or its return value is not iterable"; + // everything else (property reads, method calls, parameters) gets the + // runtime kind wording — exactly V8's CallPrinter behavior. The DYN + // pattern arm and the unit arms of a narrowed union consume this. + const src = decl.initializer!; + const dynSpell = ts.isIdentifier(src) + ? `${src.text} is not iterable` + : ts.isCallExpression(src) && ts.isIdentifier(src.expression) + ? `${src.expression.text} is not a function or its return value is not iterable` + : undefined; // JS: a union source with exactly ONE destructurable arm beside unit // arms (`nameAndArgs.match(re)` — `RegExpMatchArray | null`, the // always-matches idiom) destructures through a VALIDATED narrow: the - // unit arms throw at runtime exactly where Node's TypeError would - // (the message is the fence's — a unit value here is the failure - // path either way). TypeScript keeps the fence: narrowing first is - // the annotated fix. + // unit arms throw Node's TypeError, through the checks a dyn source + // takes (RequireObjectCoercible for an object pattern, GetIterator for + // an array pattern). An object pattern whose source has no V8 + // spelling keeps the fence. TypeScript keeps the fence: narrowing + // first is the annotated fix. if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && init.type.kind === "union") { const def = lowerer.unions.get(init.type.unionId); const dataArms = (def?.arms ?? []).filter((a) => !isUnitType(a)); @@ -2571,6 +2584,26 @@ export function lowerDestructuringDecl( const utmp = lowerer.declareHiddenLocal("%destrU", init.type); out.push({ kind: "varDecl", localId: utmp.id, init, loc }); const uref: IrExpr = { kind: "varRef", localId: utmp.id, type: init.type, loc }; + const spelling = destrSpellingOf(src); + const check: IrExpr | null = ts.isArrayBindingPattern(decl.name) + ? { + kind: "dynIterN", + value: lowerer.coerceToExpected(uref, DYN), + count: decl.name.elements.nodes.length, + ...(dynSpell ? { notIterableMessage: dynSpell } : {}), + type: DYN, + loc, + } + : spelling !== null + ? { + kind: "dynDestrCheck", + value: lowerer.coerceToExpected(uref, DYN), + spelling, + ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), + type: DYN, + loc, + } + : null; out.push({ kind: "if", cond: { @@ -2583,6 +2616,7 @@ export function lowerDestructuringDecl( loc, }, then: [ + ...(check !== null ? [{ kind: "exprStmt" as const, expr: check, loc }] : []), { kind: "runtimeFence", code: "SC1031", @@ -2635,42 +2669,19 @@ export function lowerDestructuringDecl( "destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call)", ); } - const first = (decl.name as ts.ObjectBindingPattern).elements.nodes[0]; - const firstKey = - first === undefined || first.dotDotDotToken !== undefined - ? undefined - : (first.propertyName ?? first.name); - // V8 names the first property when its key is an identifier, a string - // or a number; a computed first key takes the bare (source-only) form. - const firstProp = - firstKey === undefined || ts.isComputedPropertyName(firstKey) - ? undefined - : (patternKeyNameOf(lowerer, firstKey as ts.PropertyName) ?? undefined); out.push({ kind: "exprStmt", expr: { kind: "dynDestrCheck", value: { kind: "varRef", localId: tmp.id, type: init.type, loc }, spelling, - ...(firstProp !== undefined ? { firstProp } : {}), + ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), type: init.type, loc, }, loc, }); } - // V8's destructuring TypeError spells STATIC sources: an identifier - // source reads " is not iterable", an identifier-callee call - // " is not a function or its return value is not iterable"; - // everything else (property reads, method calls, parameters) gets the - // runtime kind wording — exactly V8's CallPrinter behavior. Only the - // DYN pattern arm consumes this. - const src = decl.initializer!; - const dynSpell = ts.isIdentifier(src) - ? `${src.text} is not iterable` - : ts.isCallExpression(src) && ts.isIdentifier(src.expression) - ? `${src.expression.text} is not a function or its return value is not iterable` - : undefined; lowerer.lowerBindingPattern( decl.name as ts.ArrayBindingPattern | ts.ObjectBindingPattern, () => ({ kind: "varRef", localId: tmp.id, type: init.type, loc }), @@ -9589,6 +9600,26 @@ export function lowerDestructuringAssign( return { kind: "block", body: parts.stmts, loc }; } +/** The first property V8 names in an object pattern's destructuring + * TypeError ("Cannot destructure property 'a' of ..."): an identifier, + * string or numeric first key. A computed first key, a rest element or + * an empty pattern takes the bare (source-only) form. */ +function firstPatternProp( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, +): { firstProp?: string } { + const first = pattern.elements.nodes[0]; + const firstKey = + first === undefined || first.dotDotDotToken !== undefined + ? undefined + : (first.propertyName ?? first.name); + const firstProp = + firstKey === undefined || ts.isComputedPropertyName(firstKey) + ? undefined + : (patternKeyNameOf(lowerer, firstKey as ts.PropertyName) ?? undefined); + return firstProp !== undefined ? { firstProp } : {}; +} + /** V8's spelling of a destructuring RHS in its TypeError message * ("Cannot destructure 'a' as it is undefined.") — plain identifiers * spell their name (parens unwrap), `this`, property chains and diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json new file mode 100644 index 000000000..f0fff1758 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/destructure-nullable-union-source.js" + ], + "diags": [] +} diff --git a/tests/corpus/destructure-nullable-union-source.js b/tests/corpus/destructure-nullable-union-source.js new file mode 100644 index 000000000..84af858bf --- /dev/null +++ b/tests/corpus/destructure-nullable-union-source.js @@ -0,0 +1,63 @@ +// A destructuring declaration whose source is one object or array type or +// null/undefined (`s.match(re)`, `items.find(...)`) throws Node's TypeError +// when the source is null or undefined, with V8's message for the source +// spelling, and binds normally otherwise. + +function show(label, fn) { + try { + fn(); + } catch (error) { + console.log(label, error instanceof TypeError, error.message); + } +} + +const text = "abc"; +show("object-call", () => { + const { index } = text.match(/z/); + console.log("never", index); +}); +show("object-empty", () => { + const {} = text.match(/z/); + console.log("never"); +}); +show("object-identifier", () => { + const found = "abc".match(/z/); + const { index, input } = found; + console.log("never", index, input); +}); +show("object-string-key", () => { + const found = "abc".match(/z/); + const { "index": at } = found; + console.log("never", at); +}); +show("array-method-call", () => { + const [whole] = "abc".match(/z/); + console.log("never", whole); +}); +show("array-identifier", () => { + const found = "abc".match(/z/); + const [whole, group] = found; + console.log("never", whole, group); +}); + +const items = [ + { name: "a", size: 1 }, + { name: "b", size: 2 }, +]; +show("object-undefined", () => { + const { name } = items.find((item) => item.size > 5); + console.log("never", name); +}); +function firstMatch(text) { + return text.match(/q/); +} +show("array-function-call", () => { + const [first] = firstMatch("abc"); + console.log("never", first); +}); + +// Sources that hold a value bind as before. +const { index } = "abc".match(/b/); +const [whole] = "abc".match(/c/); +const { name, size } = items.find((item) => item.size > 1); +console.log("bound", index, whole, name, size); diff --git a/tests/harness/corpus-costs/destructure-nullable-union-source.js.json b/tests/harness/corpus-costs/destructure-nullable-union-source.js.json new file mode 100644 index 000000000..a3fd56122 --- /dev/null +++ b/tests/harness/corpus-costs/destructure-nullable-union-source.js.json @@ -0,0 +1 @@ +{ "plain": 2635, "san": 3894 } From 32745afc07324f42060f916a55e2a98af04bfa0d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:30:06 -0500 Subject: [PATCH 058/193] Bind generic arrow captures before parameter defaults, and count var redeclarations as writes - A generic arrow instance binds its trailing capture locals before it lowers the declared parameters, so a parameter default that reads an immutable enclosing binding resolves it instead of failing with SC1090. The captures stay trailing parameters. - A binding is written when one of its own declarations writes it: a `var` in a for-in or for-of head (every iteration), or a redeclaration that initializes (`var x = 1; ... var x = 2;`, a parameter or function declaration that a `var` redeclares). Such a binding is now shared with generic instances through the environment form, so a closure an instance returns reads the current value instead of a copy. --- .../src/frontend/lowering/binding-analysis.ts | 41 ++++++++++++++++++- .../frontend/lowering/generic-functions.ts | 15 ++++--- .../generic-arrow-enclosing-captures.ts | 11 +++++ .../corpus/generic-arrow-mutable-captures.ts | 22 ++++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/binding-analysis.ts b/packages/compiler/src/frontend/lowering/binding-analysis.ts index c88c06c90..a37875f25 100644 --- a/packages/compiler/src/frontend/lowering/binding-analysis.ts +++ b/packages/compiler/src/frontend/lowering/binding-analysis.ts @@ -6,7 +6,8 @@ import { isGenericCallableMemberType } from "../type-mapper.js"; /** True when nothing in `sym`'s DECLARING FILE ever writes it after the * initializer: assignments (plain and compound, destructuring targets - * included), ++/--, and for-of/for-in expression targets all count. + * included), ++/--, for-of/for-in expression targets, a `var` in a + * for-of/for-in head, and redeclarations that initialize all count. * Sound file-locally for module-scope bindings because ESM import * bindings are read-only — no other file can write one. Cached per * symbol (the scan walks the whole file once). */ @@ -31,6 +32,7 @@ export function fileWritesBinding( file: ts.SourceFile, resolve: (ident: ts.Identifier) => ts.Symbol | null | undefined, ): boolean { + if (declarationsWriteBinding(sym)) return true; let written = false; // Text pre-check keeps the file walk cheap: only same-named // identifiers pay a symbol resolution. @@ -78,6 +80,43 @@ export function fileWritesBinding( return written; } +/** True when `sym`'s own declarations write it after it holds a value: + * a `var` in a for-in or for-of head is written on every iteration, and a + * binding declared more than once (`var x = 1; ... var x = 2;`, a + * parameter or function declaration that a `var` redeclares) is written by + * each initializing declaration that runs. Only value declarations count; + * a redeclaration without an initializer is counted too, which only + * over-approximates. */ +function declarationsWriteBinding(sym: ts.Symbol): boolean { + let values = 0; + let initializing = 0; + for (const d of sym.declarations) { + let root: ts.Node = d; + while ( + root.parent !== undefined && + (ts.isBindingElement(root) || + ts.isObjectBindingPattern(root) || + ts.isArrayBindingPattern(root)) + ) + root = root.parent; + if (ts.isVariableDeclaration(root)) { + const list = root.parent; + if ( + ts.isVariableDeclarationList(list) && + (list.flags & ts.NodeFlags.BlockScoped) === 0 && + (ts.isForInStatement(list.parent) || ts.isForOfStatement(list.parent)) + ) + return true; + values++; + if (root.initializer !== undefined) initializing++; + } else if (ts.isParameter(root) || ts.isFunctionDeclaration(d) || ts.isClassDeclaration(d)) { + values++; + initializing++; + } + } + return values > 1 && initializing > 0; +} + /** Strips the value-preserving wrappers off an expression: parens, * non-null assertions, `as`/`satisfies`/angle-bracket casts. What * remains is the expression that actually evaluates. */ diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 50f5930f4..4f49f98b0 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -1349,18 +1349,21 @@ export function lowerGenericInstance( fnCtx.captures!.push({ localId: entry.id, name: entry.name, type: entry.type }); } } - // Default-param initializers lower per instance, with the bindings - // threaded — a default mentioning T resolves like any body expression. - const declared = lowerer.declareParams(decl.parameters.nodes, inst.params); - params.push(...declared.params); // The enclosing-frame values travel as TRAILING parameters, after the // declared ones: a call lowers its arguments first (JavaScript's // order), then reads the captures. Binding each local to the captured - // symbol is what lets the body resolve the reference. + // symbol is what lets the body resolve the reference; the locals are + // bound before the declared parameters so that a default initializer + // reading a capture resolves it too. + const captureParams: IrParam[] = []; for (const capture of envForm ? [] : (info.captures ?? [])) { const local = lowerer.declareLocal(capture.nameNode, capture.name, capture.type, false); - params.push({ localId: local.id, name: capture.name, type: capture.type }); + captureParams.push({ localId: local.id, name: capture.name, type: capture.type }); } + // Default-param initializers lower per instance, with the bindings + // threaded — a default mentioning T resolves like any body expression. + const declared = lowerer.declareParams(decl.parameters.nodes, inst.params); + params.push(...declared.params, ...captureParams); const body = [...receiverInit, ...declared.prologue]; const bodyBlock = blockBodyOf(decl); if (bodyBlock) { diff --git a/tests/corpus/generic-arrow-enclosing-captures.ts b/tests/corpus/generic-arrow-enclosing-captures.ts index 3dac369da..c8c87cec1 100644 --- a/tests/corpus/generic-arrow-enclosing-captures.ts +++ b/tests/corpus/generic-arrow-enclosing-captures.ts @@ -72,3 +72,14 @@ function twice(offset: number, value: string): string { console.log(twice(10, "v")); console.log(twice(20, "w")); + +// Parameter defaults that read enclosing bindings: the captures are bound +// before the defaults lower. +function withDefault(base: number): string { + const unit = "px"; + const fmt = (value: T, suffix = unit, scale = base): string => + `${value}${suffix}x${scale}`; + return `${fmt(1)} ${fmt("2", "em")}`; +} + +console.log(withDefault(3)); diff --git a/tests/corpus/generic-arrow-mutable-captures.ts b/tests/corpus/generic-arrow-mutable-captures.ts index 50cb53e6e..64d77ee2b 100644 --- a/tests/corpus/generic-arrow-mutable-captures.ts +++ b/tests/corpus/generic-arrow-mutable-captures.ts @@ -77,10 +77,32 @@ function depth(): number { return walk(["a", "b", "c"], 0) * 10 + calls; } +// A `var` that a second declaration re-initializes is one binding the +// frame writes: a closure the generic arrow returned reads the new value. +function redeclared(): string { + var label = "first"; + const make = (value: T): (() => string) => () => `${label}:${String(value)}`; + const getter = make(1); + var label = "second"; + return `${getter()} ${make(true)()}`; +} + +// A `var` in a for-of head is one binding the loop writes on every +// iteration: closures returned in earlier iterations read the last value. +function loopVar(): string { + const readers: Array<() => string> = []; + for (var item of ["a", "b", "c"]) { + const make = (value: T): (() => string) => () => `${item}${String(value)}`; + readers.push(make(1)); + } + return readers.map((reader) => reader()).join(" "); +} + async function main(): Promise { console.log((await makeRunner("p").run(2)).join(" ")); console.log(accumulate(5)); console.log(snapshots()); console.log(depth()); + console.log(redeclared(), loopVar()); } main(); From 64f9481fb8a15bec461f2b5f3b94af2b398006d8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:41:05 -0500 Subject: [PATCH 059/193] Link the web stream classes for an element-access tee, pipeTo or pipeThrough The scan that decides whether a program uses a web stream operation the native streams lack only matched property accesses, so a TypeScript program whose one such operation was `body["tee"]()` kept the native streams and threw a TypeError at the call. Element accesses with a string literal key now count the same way, in the TypeScript scan and in the JavaScript one. Adds the corpus program web-streams-typed-element-tee.ts, with its corpus cost and order baseline. --- .../lowering/builtins/web-streams-dyn.ts | 39 +++++++++++++------ .../web-streams-typed-element-tee.ts.json | 6 +++ tests/corpus/web-streams-typed-element-tee.ts | 23 +++++++++++ .../web-streams-typed-element-tee.ts.json | 1 + 4 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-element-tee.ts.json create mode 100644 tests/corpus/web-streams-typed-element-tee.ts create mode 100644 tests/harness/corpus-costs/web-streams-typed-element-tee.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts index 67caec84a..809d5bb03 100644 --- a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts +++ b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts @@ -119,11 +119,8 @@ export function programUsesTypedWebStreams(lowerer: Lowerer): boolean { for (const file of lowerer.program.getSourceFiles()) { if (file.isDeclarationFile || isJsSourceFile(file)) continue; ts.walkPreorder(file, (node) => { - if ( - ts.isPropertyAccessExpression(node) && - WEB_VIEW_CALLS.has(node.name.text) && - isReadableStreamMember(lowerer, node) - ) + const member = staticMemberName(node); + if (member !== null && WEB_VIEW_CALLS.has(member) && isReadableStreamMember(lowerer, node)) found = true; else if (ts.isNewExpression(node)) { const name = stdlibGlobalNameOf(lowerer, unparenthesized(node.expression)); @@ -316,10 +313,29 @@ function isReadableStreamType(lowerer: Lowerer, type: ts.Type): boolean { ); } -/** Whether a member access resolves to a member of the stdlib - * ReadableStream interface. */ -function isReadableStreamMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean { - const member = lowerer.checker.getSymbolAtLocation(access.name); +type MemberAccess = ts.PropertyAccessExpression | ts.ElementAccessExpression; + +/** The member a property access names, or an element access with a string + * literal key (`body.tee` and `body["tee"]`); null for any other node. */ +function staticMemberName(node: ts.Node): string | null { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + if (ts.isElementAccessExpression(node)) { + const key = node.argumentExpression; + if (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) return key.text; + } + return null; +} + +/** Whether a member access (staticMemberName) resolves to a member of the + * stdlib ReadableStream interface. */ +function isReadableStreamMember(lowerer: Lowerer, access: ts.Node): boolean { + const key = ts.isPropertyAccessExpression(access) + ? access.name + : ts.isElementAccessExpression(access) + ? access.argumentExpression + : null; + if (key === null) return false; + const member = lowerer.checker.getSymbolAtLocation(key); if (member === undefined || !lowerer.isStdlibSymbol(member)) return false; let owner: ts.Node | undefined = member.declarations[0]?.parent; while (owner !== undefined && !ts.isInterfaceDeclaration(owner)) owner = owner.parent; @@ -366,9 +382,10 @@ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction let found = false; ts.walkPreorder(file, (node) => { let receiver: ts.Type | null = null; - if (ts.isPropertyAccessExpression(node) && members.has(node.name.text)) { + const member = staticMemberName(node); + if (member !== null && members.has(member)) { if (isReadableStreamMember(lowerer, node)) found = true; - else receiver = lowerer.typeOf(node.expression); + else receiver = lowerer.typeOf((node as MemberAccess).expression); } else if (iterates && ts.isForOfStatement(node) && node.awaitModifier !== undefined) { receiver = lowerer.typeOf(node.expression); if (isReadableStreamType(lowerer, receiver)) found = true; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-element-tee.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-element-tee.ts.json new file mode 100644 index 000000000..61b037166 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-streams-typed-element-tee.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/web-streams-typed-element-tee.ts" + ], + "diags": [] +} diff --git a/tests/corpus/web-streams-typed-element-tee.ts b/tests/corpus/web-streams-typed-element-tee.ts new file mode 100644 index 000000000..744ff6bb3 --- /dev/null +++ b/tests/corpus/web-streams-typed-element-tee.ts @@ -0,0 +1,23 @@ +// ReadableStream tee() reached through an element access with a string key +// is the program's only web stream operation: the program still links the +// web stream classes, so a Response body splits into two branches. + +async function text(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + for (;;) { + const result = await reader.read(); + if (result.done) break; + out += decoder.decode(result.value, { stream: true }); + } + return out + decoder.decode(); +} + +async function main(): Promise { + const body = new Response("split me").body!; + const [left, right] = body["tee"](); + console.log("element tee", await text(left), "|", await text(right), body.locked); +} + +void main(); diff --git a/tests/harness/corpus-costs/web-streams-typed-element-tee.ts.json b/tests/harness/corpus-costs/web-streams-typed-element-tee.ts.json new file mode 100644 index 000000000..5cce6d4a6 --- /dev/null +++ b/tests/harness/corpus-costs/web-streams-typed-element-tee.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 6000 } From de5c5c251729f74fba5edd9891aae74b387a3dc5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:54:25 -0500 Subject: [PATCH 060/193] Follow renamed and default imports and exports in reuse's changed names A changed name now also changes the names that hold it under another name: a renamed or default import, a renamed or default export, an `import x = A.b` and a renamed `require()` destructuring. A unit that spells only the local name of such an import lowers again when the imported declaration changes. Regenerate codec.generated.ts for the bigintStats IR type, and round-trip a BigIntStats program in the codec tests. --- .../src/frontend/lowering/unit-reuse.ts | 143 ++- packages/compiler/src/ir/codec.generated.ts | 922 +++++++++--------- packages/compiler/src/ir/codec.test.ts | 3 +- packages/compiler/test/unit-reuse.test.ts | 65 ++ 4 files changed, 651 insertions(+), 482 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/unit-reuse.ts b/packages/compiler/src/frontend/lowering/unit-reuse.ts index 5de2c7b0a..0a5289c77 100644 --- a/packages/compiler/src/frontend/lowering/unit-reuse.ts +++ b/packages/compiler/src/frontend/lowering/unit-reuse.ts @@ -31,7 +31,10 @@ * itself, since analyses read a binding's or property's writes anywhere; * - the names that refer to a module's namespace object, by a namespace * import or an `import()` or `require()` call, when the module's exports - * changed. + * changed; + * - the names that hold one of these under another name (Alias): a renamed + * or default import, a renamed or default export, a renamed `require()` + * destructuring, and the aliases of those. * * TypeScript resolves every dependency of a type through a name some text * spells, so a unit whose text spells none of these sees the same types, @@ -537,6 +540,71 @@ function namespaceSites( }); } +/** A name a binding of a module's text holds under another name: `from` + * is the imported or exported name, `to` the local or exported one + * (`import { from as to }`, `import to from`, whose `from` is `default`, + * `export { from as to }`, `export default from`). Text that spells `to` + * reaches the declaration of `from`. */ +type Alias = readonly [from: string, to: string]; + +/** The aliases `statement` makes (Alias). `names` are the names it + * declares. */ +function aliasesOf(statement: ts.Statement, names: readonly string[], out: Alias[]): void { + const add = (from: string, to: string): void => { + if (from !== to) out.push([from, to]); + }; + const specifiers = (elements: readonly (ts.ImportSpecifier | ts.ExportSpecifier)[]): void => { + for (const element of elements) + add((element.propertyName ?? element.name).text, element.name.text); + }; + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause; + if (clause?.name !== undefined) add("default", clause.name.text); + const bindings = clause?.namedBindings; + if (bindings !== undefined && ts.isNamedImports(bindings)) specifiers(bindings.elements.nodes); + return; + } + if (ts.isImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + if (ts.isIdentifier(reference)) add(reference.text, statement.name.text); + else if (ts.isQualifiedName(reference)) add(reference.right.text, statement.name.text); + return; + } + if (ts.isExportDeclaration(statement)) { + const clause = statement.exportClause; + if (clause !== undefined && ts.isNamedExports(clause)) specifiers(clause.elements.nodes); + return; + } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + if ( + ts.isExportAssignment(statement) || + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true + ) { + for (const name of names) add(name, "default"); + return; + } + // `const { from: to } = require("...")`. + if (ts.isVariableStatement(statement)) + for (const declaration of statement.declarationList.declarations.nodes) { + const initializer = declaration.initializer; + if ( + !ts.isObjectBindingPattern(declaration.name) || + initializer === undefined || + !ts.isCallExpression(initializer) || + !ts.isIdentifier(initializer.expression) || + initializer.expression.text !== "require" + ) + continue; + for (const element of declaration.name.elements.nodes) + if ( + element.propertyName !== undefined && + ts.isIdentifier(element.propertyName) && + ts.isIdentifier(element.name) + ) + add(element.propertyName.text, element.name.text); + } +} + /** A module's declaration table, its declarations' nodes, and the text of * its statements with global effect. A file that is not a module has global * effect as a whole. */ @@ -544,7 +612,7 @@ function fileTable( sf: ts.SourceFile, hooks: UnitReuseHooks, previous: FileTable | undefined, -): { table: FileTable; nodes: DeclarationNode[]; global: string } { +): { table: FileTable; nodes: DeclarationNode[]; aliases: Alias[]; global: string } { // A declaration with the text it had in the previous build writes what it // wrote then. const before = new Map((previous?.declarations ?? []).map((d) => [d.key, d])); @@ -556,6 +624,7 @@ function fileTable( const declarations: Declaration[] = []; const namespaces: NamespaceSite[] = []; const nodes: DeclarationNode[] = []; + const aliases: Alias[] = []; const keys = new Set(); let global = ""; const unique = (key: string): string => { @@ -568,6 +637,7 @@ function fileTable( for (const statement of sf.statements.nodes) { if (ts.isImportDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) { namespaceSites(sf, statement, [], hooks, namespaces); + aliasesOf(statement, [], aliases); continue; } if (!module || hasGlobalEffect(statement)) { @@ -576,6 +646,7 @@ function fileTable( } const names = statementNames(statement, js, hooks); namespaceSites(sf, statement, names, hooks, namespaces); + aliasesOf(statement, names, aliases); const kind = String(statement.kind); if (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) { // Each member is a declaration of its own; the rest of the class or @@ -647,6 +718,7 @@ function fileTable( namespaces, }, nodes, + aliases, global, }; } @@ -710,10 +782,12 @@ export class UnitReuse { private readonly tables: Map, previous: { program: string; tables: Map } | null, nodes: readonly DeclarationNode[], + aliases: readonly Alias[], ) { this.previous = previous !== null && previous.program === program ? previous.tables : null; this.records = previous !== null; - this.changed = this.previous === null ? new Set() : changedNames(this.previous, tables, nodes); + this.changed = + this.previous === null ? new Set() : changedNames(this.previous, tables, nodes, aliases); this.stats.changedNames = this.changed.size; } @@ -729,6 +803,7 @@ export class UnitReuse { const own = new Set(files); const tables = new Map(); const nodes: DeclarationNode[] = []; + const aliases: Alias[] = []; const previous = readProgram(store); const digest = createHash("sha256").update( `${PROGRAM_MAGIC}\n${IR_VERSION}\n${IR_CODEC_LAYOUT}\n`, @@ -737,6 +812,7 @@ export class UnitReuse { const file = fileTable(sf, hooks, previous?.tables.get(sf.fileName)); tables.set(sf.fileName, file.table); for (const node of file.nodes) nodes.push(node); + for (const alias of file.aliases) aliases.push(alias); digest.update(`${sf.fileName}\n${digestOf(file.global)}\n`); } const others = program @@ -745,7 +821,7 @@ export class UnitReuse { .map((sf) => `${sf.fileName}\n${digestOf(sf.text)}\n`) .sort(); for (const other of others) digest.update(other); - const reuse = new UnitReuse(store, digest.digest("hex"), tables, previous, nodes); + const reuse = new UnitReuse(store, digest.digest("hex"), tables, previous, nodes, aliases); reuse.stats.openMs = performance.now() - started; return reuse; } @@ -863,6 +939,7 @@ function changedNames( before: Map, now: Map, nodes: readonly DeclarationNode[], + aliases: readonly Alias[], ): Set { const surfaces = new Set(); const direct = new Set(); @@ -907,31 +984,47 @@ function changedNames( }; for (const [path, table] of now) compare(before.get(path), table); for (const [path, table] of before) if (!now.has(path)) compare(table, undefined); - if (surfaces.size > 0) { + // A name that holds a changed name under another name changes the same + // way (Alias). + const follow = (): boolean => { + let grew = false; + for (const [from, to] of aliases) { + if (surfaces.has(from) && !surfaces.has(to)) { + surfaces.add(to); + grew = true; + } + if (direct.has(from) && !direct.has(to)) { + direct.add(to); + grew = true; + } + } + return grew; + }; + let pending: { declaration: DeclarationNode; spells: Set }[] | null = null; + for (let grew = true; grew;) { + grew = follow(); + if (surfaces.size === 0) continue; // A declaration whose surface spells a changed name can have another - // type now (typeChanged); repeat until no surface adds a name. - const pending = nodes.map((declaration) => ({ + // type now (typeChanged); repeat until no surface or alias adds a name. + pending ??= nodes.map((declaration) => ({ declaration, spells: surfaceNames(declaration), })); - for (let grew = true; grew;) { - grew = false; - for (let index = 0; index < pending.length; index++) { - const entry = pending[index]!; - let hit = false; - for (const name of entry.spells) - if (surfaces.has(name)) { - hit = true; - break; - } - if (!hit) continue; - typeChanged(entry.declaration); - // Each declaration joins once. - pending[index] = pending[pending.length - 1]!; - pending.pop(); - index--; - grew = true; - } + for (let index = 0; index < pending.length; index++) { + const entry = pending[index]!; + let hit = false; + for (const name of entry.spells) + if (surfaces.has(name)) { + hit = true; + break; + } + if (!hit) continue; + typeChanged(entry.declaration); + // Each declaration joins once. + pending[index] = pending[pending.length - 1]!; + pending.pop(); + index--; + grew = true; } } for (const name of direct) surfaces.add(name); diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index 40d5fe3d2..064757b5e 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "978da33223a8447d"; +export const IR_CODEC_LAYOUT = "6493ba8347f973e2"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -32,13 +32,13 @@ const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","in const KEYS5: readonly string[] = ["kind","elem","optionalElements"]; const KEYS6: readonly string[] = ["kind"]; const KEYS7: readonly string[] = ["kind"]; -const KEYS8: readonly string[] = ["kind","elem"]; -const KEYS9: readonly string[] = ["kind"]; +const KEYS8: readonly string[] = ["kind"]; +const KEYS9: readonly string[] = ["kind","elem"]; const KEYS10: readonly string[] = ["kind"]; const KEYS11: readonly string[] = ["kind"]; const KEYS12: readonly string[] = ["kind"]; -const KEYS13: readonly string[] = ["kind","className"]; -const KEYS14: readonly string[] = ["kind"]; +const KEYS13: readonly string[] = ["kind"]; +const KEYS14: readonly string[] = ["kind","className"]; const KEYS15: readonly string[] = ["kind"]; const KEYS16: readonly string[] = ["kind"]; const KEYS17: readonly string[] = ["kind"]; @@ -47,199 +47,200 @@ const KEYS19: readonly string[] = ["kind"]; const KEYS20: readonly string[] = ["kind"]; const KEYS21: readonly string[] = ["kind"]; const KEYS22: readonly string[] = ["kind"]; -const KEYS23: readonly string[] = ["kind","params","ret","rest","restAbi","argumentsAll"]; -const KEYS24: readonly string[] = ["kind","async","yieldT","retT","nextT"]; -const KEYS25: readonly string[] = ["kind"]; +const KEYS23: readonly string[] = ["kind"]; +const KEYS24: readonly string[] = ["kind","params","ret","rest","restAbi","argumentsAll"]; +const KEYS25: readonly string[] = ["kind","async","yieldT","retT","nextT"]; const KEYS26: readonly string[] = ["kind"]; const KEYS27: readonly string[] = ["kind"]; const KEYS28: readonly string[] = ["kind"]; const KEYS29: readonly string[] = ["kind"]; const KEYS30: readonly string[] = ["kind"]; -const KEYS31: readonly string[] = ["kind","key","value"]; -const KEYS32: readonly string[] = ["kind","moduleId"]; -const KEYS33: readonly string[] = ["kind"]; +const KEYS31: readonly string[] = ["kind"]; +const KEYS32: readonly string[] = ["kind","key","value"]; +const KEYS33: readonly string[] = ["kind","moduleId"]; const KEYS34: readonly string[] = ["kind"]; const KEYS35: readonly string[] = ["kind"]; -const KEYS36: readonly string[] = ["kind","className"]; -const KEYS37: readonly string[] = ["kind"]; -const KEYS38: readonly string[] = ["kind","inner"]; -const KEYS39: readonly string[] = ["kind","shapeId"]; -const KEYS40: readonly string[] = ["kind"]; +const KEYS36: readonly string[] = ["kind"]; +const KEYS37: readonly string[] = ["kind","className"]; +const KEYS38: readonly string[] = ["kind"]; +const KEYS39: readonly string[] = ["kind","inner"]; +const KEYS40: readonly string[] = ["kind","shapeId"]; const KEYS41: readonly string[] = ["kind"]; const KEYS42: readonly string[] = ["kind"]; -const KEYS43: readonly string[] = ["kind","elem"]; -const KEYS44: readonly string[] = ["kind"]; +const KEYS43: readonly string[] = ["kind"]; +const KEYS44: readonly string[] = ["kind","elem"]; const KEYS45: readonly string[] = ["kind"]; const KEYS46: readonly string[] = ["kind"]; const KEYS47: readonly string[] = ["kind"]; const KEYS48: readonly string[] = ["kind"]; const KEYS49: readonly string[] = ["kind"]; -const KEYS50: readonly string[] = ["kind","unionId"]; -const KEYS51: readonly string[] = ["kind"]; +const KEYS50: readonly string[] = ["kind"]; +const KEYS51: readonly string[] = ["kind","unionId"]; const KEYS52: readonly string[] = ["kind"]; -const KEYS53: readonly string[] = ["file","start","end"]; -const KEYS54: readonly string[] = ["yieldT","nextT","resultType","closesSource","tag","inheritsTag"]; -const KEYS55: readonly string[] = ["name","localCaptures","localBaseCapture","baseValueGlobal","jsName","jsLength","ownStaticFields","prototypeDataHelper","instancePrototypeHelper","stringTagHelper","tracksOwnFields","errorNameAfter","errorBagOrder","symbolFields","runtime","arrayElem","arrayFields","arraySpecies","base","fields","methods","prototypeNames","abstract","abstractMethods","genericOf","loc"]; -const KEYS56: readonly string[] = ["id","name","type","mutable","tdz","initFlag","source"]; -const KEYS57: readonly string[] = ["id","fields","tuple","indexValue","freezable","declaredOrder","accessorOrder","orderVariants"]; -const KEYS58: readonly string[] = ["id","arms","discriminant"]; -const KEYS59: readonly string[] = ["file","pkg"]; -const KEYS60: readonly string[] = ["root","files","links","location"]; -const KEYS61: readonly string[] = ["name","symbol","library","callbackOperation","callbackTarget","params","returns"]; -const KEYS62: readonly string[] = ["buildIdSymbol","abiVersionSymbol","buildId","abiVersion"]; -const KEYS63: readonly string[] = ["sha256","bytes"]; -const KEYS64: readonly string[] = ["sha256","size","reason"]; -const KEYS65: readonly string[] = ["localId","name","type","owned"]; -const KEYS66: readonly string[] = ["id","name","type","mutable","source","boxed","tdz"]; -const KEYS67: readonly string[] = ["localId","name","type","owned","slot"]; -const KEYS68: readonly string[] = ["kind","arr","index","loc"]; -const KEYS69: readonly string[] = ["kind","arr","index","value","loc"]; -const KEYS70: readonly string[] = ["kind","arr","length","loc","pop","reserve"]; -const KEYS71: readonly string[] = ["kind","arr","index","loc"]; -const KEYS72: readonly string[] = ["kind","localId","value","initializes","loc"]; -const KEYS73: readonly string[] = ["kind","labels","body","loc"]; -const KEYS74: readonly string[] = ["kind","label","loc"]; -const KEYS75: readonly string[] = ["kind","arr","index","value","loc"]; -const KEYS76: readonly string[] = ["kind","label","loc"]; -const KEYS77: readonly string[] = ["kind","body","cond","labels","loc"]; -const KEYS78: readonly string[] = ["kind","expr","loc"]; -const KEYS79: readonly string[] = ["kind","obj","className","field","value","sloppy","define","attributesChecked","loc"]; -const KEYS80: readonly string[] = ["kind","init","cond","update","body","labels","loc"]; -const KEYS81: readonly string[] = ["kind","localId","iterable","body","labels","loc"]; -const KEYS82: readonly string[] = ["kind","cond","then","else_","loc"]; -const KEYS83: readonly string[] = ["kind","obj","shapeId","key","loc"]; -const KEYS84: readonly string[] = ["kind","obj","shapeId","key","value","overflowOnly","loc"]; -const KEYS85: readonly string[] = ["kind","obj","shapeId","field","value","loc"]; -const KEYS86: readonly string[] = ["kind","localId","loc"]; -const KEYS87: readonly string[] = ["kind","value","awaited","loc"]; -const KEYS88: readonly string[] = ["kind","code","message","loc"]; -const KEYS89: readonly string[] = ["kind","disc","cases","labels","loc"]; -const KEYS90: readonly string[] = ["kind","value","loc"]; -const KEYS91: readonly string[] = ["kind","tryBody","catchBody","catchLocalId","dropFinallyErrors","finallyBody","suppressFinallyErrors","loc"]; -const KEYS92: readonly string[] = ["kind","localId","init","loc"]; -const KEYS93: readonly string[] = ["kind","cond","body","labels","loc"]; -const KEYS94: readonly string[] = ["kind","shapeId"]; -const KEYS95: readonly string[] = ["loc","scope"]; -const KEYS96: readonly string[] = ["field","cases"]; -const KEYS97: readonly string[] = ["name","slot","params","returns","unregisteredTrap"]; -const KEYS98: readonly string[] = ["symbol","fnName","params","returns","inboundBytesTrap","inboundIntTrap","inboundCstringTrap"]; -const KEYS99: readonly string[] = ["code","teaching","remediation"]; -const KEYS100: readonly string[] = ["kind","method","receiver","args","type","loc"]; -const KEYS101: readonly string[] = ["kind","arr","index","type","loc"]; +const KEYS53: readonly string[] = ["kind"]; +const KEYS54: readonly string[] = ["file","start","end"]; +const KEYS55: readonly string[] = ["yieldT","nextT","resultType","closesSource","tag","inheritsTag"]; +const KEYS56: readonly string[] = ["name","localCaptures","localBaseCapture","baseValueGlobal","jsName","jsLength","ownStaticFields","prototypeDataHelper","instancePrototypeHelper","stringTagHelper","tracksOwnFields","errorNameAfter","errorBagOrder","symbolFields","runtime","arrayElem","arrayFields","arraySpecies","base","fields","methods","prototypeNames","abstract","abstractMethods","genericOf","loc"]; +const KEYS57: readonly string[] = ["id","name","type","mutable","tdz","initFlag","source"]; +const KEYS58: readonly string[] = ["id","fields","tuple","indexValue","freezable","declaredOrder","accessorOrder","orderVariants"]; +const KEYS59: readonly string[] = ["id","arms","discriminant"]; +const KEYS60: readonly string[] = ["file","pkg"]; +const KEYS61: readonly string[] = ["root","files","links","location"]; +const KEYS62: readonly string[] = ["name","symbol","library","callbackOperation","callbackTarget","params","returns"]; +const KEYS63: readonly string[] = ["buildIdSymbol","abiVersionSymbol","buildId","abiVersion"]; +const KEYS64: readonly string[] = ["sha256","bytes"]; +const KEYS65: readonly string[] = ["sha256","size","reason"]; +const KEYS66: readonly string[] = ["localId","name","type","owned"]; +const KEYS67: readonly string[] = ["id","name","type","mutable","source","boxed","tdz"]; +const KEYS68: readonly string[] = ["localId","name","type","owned","slot"]; +const KEYS69: readonly string[] = ["kind","arr","index","loc"]; +const KEYS70: readonly string[] = ["kind","arr","index","value","loc"]; +const KEYS71: readonly string[] = ["kind","arr","length","loc","pop","reserve"]; +const KEYS72: readonly string[] = ["kind","arr","index","loc"]; +const KEYS73: readonly string[] = ["kind","localId","value","initializes","loc"]; +const KEYS74: readonly string[] = ["kind","labels","body","loc"]; +const KEYS75: readonly string[] = ["kind","label","loc"]; +const KEYS76: readonly string[] = ["kind","arr","index","value","loc"]; +const KEYS77: readonly string[] = ["kind","label","loc"]; +const KEYS78: readonly string[] = ["kind","body","cond","labels","loc"]; +const KEYS79: readonly string[] = ["kind","expr","loc"]; +const KEYS80: readonly string[] = ["kind","obj","className","field","value","sloppy","define","attributesChecked","loc"]; +const KEYS81: readonly string[] = ["kind","init","cond","update","body","labels","loc"]; +const KEYS82: readonly string[] = ["kind","localId","iterable","body","labels","loc"]; +const KEYS83: readonly string[] = ["kind","cond","then","else_","loc"]; +const KEYS84: readonly string[] = ["kind","obj","shapeId","key","loc"]; +const KEYS85: readonly string[] = ["kind","obj","shapeId","key","value","overflowOnly","loc"]; +const KEYS86: readonly string[] = ["kind","obj","shapeId","field","value","loc"]; +const KEYS87: readonly string[] = ["kind","localId","loc"]; +const KEYS88: readonly string[] = ["kind","value","awaited","loc"]; +const KEYS89: readonly string[] = ["kind","code","message","loc"]; +const KEYS90: readonly string[] = ["kind","disc","cases","labels","loc"]; +const KEYS91: readonly string[] = ["kind","value","loc"]; +const KEYS92: readonly string[] = ["kind","tryBody","catchBody","catchLocalId","dropFinallyErrors","finallyBody","suppressFinallyErrors","loc"]; +const KEYS93: readonly string[] = ["kind","localId","init","loc"]; +const KEYS94: readonly string[] = ["kind","cond","body","labels","loc"]; +const KEYS95: readonly string[] = ["kind","shapeId"]; +const KEYS96: readonly string[] = ["loc","scope"]; +const KEYS97: readonly string[] = ["field","cases"]; +const KEYS98: readonly string[] = ["name","slot","params","returns","unregisteredTrap"]; +const KEYS99: readonly string[] = ["symbol","fnName","params","returns","inboundBytesTrap","inboundIntTrap","inboundCstringTrap"]; +const KEYS100: readonly string[] = ["code","teaching","remediation"]; +const KEYS101: readonly string[] = ["kind","method","receiver","args","type","loc"]; const KEYS102: readonly string[] = ["kind","arr","index","type","loc"]; -const KEYS103: readonly string[] = ["kind","elems","spreads","capacity","type","loc"]; -const KEYS104: readonly string[] = ["kind","length","type","loc"]; -const KEYS105: readonly string[] = ["kind","arr","index","type","loc"]; -const KEYS106: readonly string[] = ["kind","localId","value","type","loc"]; -const KEYS107: readonly string[] = ["kind","value","type","adopt","loc"]; -const KEYS108: readonly string[] = ["kind","value","promiseTag","type","loc"]; -const KEYS109: readonly string[] = ["kind","op","left","right","type","loc"]; -const KEYS110: readonly string[] = ["kind","value","type","loc"]; -const KEYS111: readonly string[] = ["kind","method","receiver","args","type","loc","invalidNaN"]; -const KEYS112: readonly string[] = ["kind","source","from","type","loc"]; -const KEYS113: readonly string[] = ["kind","callee","args","type","loc"]; -const KEYS114: readonly string[] = ["kind","callee","receiver","args","type","loc"]; -const KEYS115: readonly string[] = ["kind","value","className","type","loc"]; -const KEYS116: readonly string[] = ["kind","value","type","loc"]; -const KEYS117: readonly string[] = ["kind","value","test","className","negated","type","loc"]; -const KEYS118: readonly string[] = ["kind","value","type","loc"]; -const KEYS119: readonly string[] = ["kind","id","type","loc"]; -const KEYS120: readonly string[] = ["kind","className","type","loc","captures"]; -const KEYS121: readonly string[] = ["kind","fnName","captures","adapts","type","loc"]; -const KEYS122: readonly string[] = ["kind","value","type","loc"]; -const KEYS123: readonly string[] = ["kind","elems","type","loc"]; -const KEYS124: readonly string[] = ["kind","callee","receiver","calleeName","calleeNameValue","args","spreads","type","loc"]; -const KEYS125: readonly string[] = ["kind","value","preserveRefs","adopt","exact","inferredSite","origin","type","loc"]; -const KEYS126: readonly string[] = ["kind","value","spelling","firstProp","type","loc"]; -const KEYS127: readonly string[] = ["kind","value","type","loc","liveRef","fnName","fnLength"]; -const KEYS128: readonly string[] = ["kind","key","negated","value","type","loc"]; -const KEYS129: readonly string[] = ["kind","recv","method","calleeName","calleeNameValue","args","type","loc"]; -const KEYS130: readonly string[] = ["kind","value","count","notIterableMessage","type","loc"]; -const KEYS131: readonly string[] = ["kind","key","optional","value","type","loc"]; -const KEYS132: readonly string[] = ["kind","fields","type","loc"]; -const KEYS133: readonly string[] = ["kind","left","right","negated","type","loc"]; -const KEYS134: readonly string[] = ["kind","test","bytesElem","negated","value","type","loc"]; -const KEYS135: readonly string[] = ["kind","import","args","type","loc"]; -const KEYS136: readonly string[] = ["kind","unionId","type","loc"]; -const KEYS137: readonly string[] = ["kind","obj","className","field","type","loc"]; -const KEYS138: readonly string[] = ["kind","op","prefix","obj","className","field","fieldDyn","type","loc"]; -const KEYS139: readonly string[] = ["kind","mode","gen","arg","type","loc"]; -const KEYS140: readonly string[] = ["kind","op","prefix","localId","type","loc"]; -const KEYS141: readonly string[] = ["kind","value","className","type","loc"]; -const KEYS142: readonly string[] = ["kind","value","classValue","type","loc"]; -const KEYS143: readonly string[] = ["kind","name","args","type","loc"]; -const KEYS144: readonly string[] = ["kind","value","indent","circular","type","loc"]; -const KEYS145: readonly string[] = ["kind","fn","args","type","loc","prototypeIdentityOnly","realTimer","assertOptions"]; -const KEYS146: readonly string[] = ["kind","op","left","right","type","loc"]; -const KEYS147: readonly string[] = ["kind","method","receiver","args","type","loc"]; -const KEYS148: readonly string[] = ["kind","seed","type","loc"]; -const KEYS149: readonly string[] = ["kind","moduleId","type","loc"]; -const KEYS150: readonly string[] = ["kind","className","args","type","loc"]; -const KEYS151: readonly string[] = ["kind","executor","type","loc"]; -const KEYS152: readonly string[] = ["kind","callee","args","type","loc"]; -const KEYS153: readonly string[] = ["kind","left","right","type","loc"]; -const KEYS154: readonly string[] = ["kind","value","spelling","type","loc"]; -const KEYS155: readonly string[] = ["kind","id","receiver","body","type","loc"]; -const KEYS156: readonly string[] = ["kind","left","right","retag","type","loc"]; -const KEYS157: readonly string[] = ["kind","value","type","loc"]; -const KEYS158: readonly string[] = ["kind","type","loc"]; -const KEYS159: readonly string[] = ["kind","source","overrides","type","loc"]; -const KEYS160: readonly string[] = ["kind","obj","shapeId","field","type","loc"]; +const KEYS103: readonly string[] = ["kind","arr","index","type","loc"]; +const KEYS104: readonly string[] = ["kind","elems","spreads","capacity","type","loc"]; +const KEYS105: readonly string[] = ["kind","length","type","loc"]; +const KEYS106: readonly string[] = ["kind","arr","index","type","loc"]; +const KEYS107: readonly string[] = ["kind","localId","value","type","loc"]; +const KEYS108: readonly string[] = ["kind","value","type","adopt","loc"]; +const KEYS109: readonly string[] = ["kind","value","promiseTag","type","loc"]; +const KEYS110: readonly string[] = ["kind","op","left","right","type","loc"]; +const KEYS111: readonly string[] = ["kind","value","type","loc"]; +const KEYS112: readonly string[] = ["kind","method","receiver","args","type","loc","invalidNaN"]; +const KEYS113: readonly string[] = ["kind","source","from","type","loc"]; +const KEYS114: readonly string[] = ["kind","callee","args","type","loc"]; +const KEYS115: readonly string[] = ["kind","callee","receiver","args","type","loc"]; +const KEYS116: readonly string[] = ["kind","value","className","type","loc"]; +const KEYS117: readonly string[] = ["kind","value","type","loc"]; +const KEYS118: readonly string[] = ["kind","value","test","className","negated","type","loc"]; +const KEYS119: readonly string[] = ["kind","value","type","loc"]; +const KEYS120: readonly string[] = ["kind","id","type","loc"]; +const KEYS121: readonly string[] = ["kind","className","type","loc","captures"]; +const KEYS122: readonly string[] = ["kind","fnName","captures","adapts","type","loc"]; +const KEYS123: readonly string[] = ["kind","value","type","loc"]; +const KEYS124: readonly string[] = ["kind","elems","type","loc"]; +const KEYS125: readonly string[] = ["kind","callee","receiver","calleeName","calleeNameValue","args","spreads","type","loc"]; +const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","inferredSite","origin","type","loc"]; +const KEYS127: readonly string[] = ["kind","value","spelling","firstProp","type","loc"]; +const KEYS128: readonly string[] = ["kind","value","type","loc","liveRef","fnName","fnLength"]; +const KEYS129: readonly string[] = ["kind","key","negated","value","type","loc"]; +const KEYS130: readonly string[] = ["kind","recv","method","calleeName","calleeNameValue","args","type","loc"]; +const KEYS131: readonly string[] = ["kind","value","count","notIterableMessage","type","loc"]; +const KEYS132: readonly string[] = ["kind","key","optional","value","type","loc"]; +const KEYS133: readonly string[] = ["kind","fields","type","loc"]; +const KEYS134: readonly string[] = ["kind","left","right","negated","type","loc"]; +const KEYS135: readonly string[] = ["kind","test","bytesElem","negated","value","type","loc"]; +const KEYS136: readonly string[] = ["kind","import","args","type","loc"]; +const KEYS137: readonly string[] = ["kind","unionId","type","loc"]; +const KEYS138: readonly string[] = ["kind","obj","className","field","type","loc"]; +const KEYS139: readonly string[] = ["kind","op","prefix","obj","className","field","fieldDyn","type","loc"]; +const KEYS140: readonly string[] = ["kind","mode","gen","arg","type","loc"]; +const KEYS141: readonly string[] = ["kind","op","prefix","localId","type","loc"]; +const KEYS142: readonly string[] = ["kind","value","className","type","loc"]; +const KEYS143: readonly string[] = ["kind","value","classValue","type","loc"]; +const KEYS144: readonly string[] = ["kind","name","args","type","loc"]; +const KEYS145: readonly string[] = ["kind","value","indent","circular","type","loc"]; +const KEYS146: readonly string[] = ["kind","fn","args","type","loc","prototypeIdentityOnly","realTimer","assertOptions"]; +const KEYS147: readonly string[] = ["kind","op","left","right","type","loc"]; +const KEYS148: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS149: readonly string[] = ["kind","seed","type","loc"]; +const KEYS150: readonly string[] = ["kind","moduleId","type","loc"]; +const KEYS151: readonly string[] = ["kind","className","args","type","loc"]; +const KEYS152: readonly string[] = ["kind","executor","type","loc"]; +const KEYS153: readonly string[] = ["kind","callee","args","type","loc"]; +const KEYS154: readonly string[] = ["kind","left","right","type","loc"]; +const KEYS155: readonly string[] = ["kind","value","spelling","type","loc"]; +const KEYS156: readonly string[] = ["kind","id","receiver","body","type","loc"]; +const KEYS157: readonly string[] = ["kind","left","right","retag","type","loc"]; +const KEYS158: readonly string[] = ["kind","value","type","loc"]; +const KEYS159: readonly string[] = ["kind","type","loc"]; +const KEYS160: readonly string[] = ["kind","source","overrides","type","loc"]; const KEYS161: readonly string[] = ["kind","obj","shapeId","field","type","loc"]; -const KEYS162: readonly string[] = ["kind","obj","shapeId","key","overflowOnly","missNaN","type","loc"]; -const KEYS163: readonly string[] = ["kind","fields","nullProto","type","loc"]; -const KEYS164: readonly string[] = ["kind","obj","shapeId","type","loc"]; -const KEYS165: readonly string[] = ["kind","obj","shapeId","key","type","loc"]; -const KEYS166: readonly string[] = ["kind","obj","shapeId","type","loc"]; -const KEYS167: readonly string[] = ["kind","method","receiver","args","type","loc"]; -const KEYS168: readonly string[] = ["kind","pattern","flags","type","loc"]; -const KEYS169: readonly string[] = ["kind","type","loc"]; -const KEYS170: readonly string[] = ["kind","stmts","result","generatorDelegate","diverges","type","loc"]; -const KEYS171: readonly string[] = ["kind","method","receiver","args","type","loc"]; -const KEYS172: readonly string[] = ["kind","seed","type","loc"]; -const KEYS173: readonly string[] = ["kind","op","left","right","type","loc"]; -const KEYS174: readonly string[] = ["kind","left","right","type","loc"]; -const KEYS175: readonly string[] = ["kind","negated","left","right","type","loc"]; -const KEYS176: readonly string[] = ["kind","method","receiver","args","type","loc"]; -const KEYS177: readonly string[] = ["kind","value","type","loc"]; -const KEYS178: readonly string[] = ["kind","key","cooked","type","loc"]; -const KEYS179: readonly string[] = ["kind","cond","then","else_","type","loc"]; -const KEYS180: readonly string[] = ["kind","operand","type","loc"]; +const KEYS162: readonly string[] = ["kind","obj","shapeId","field","type","loc"]; +const KEYS163: readonly string[] = ["kind","obj","shapeId","key","overflowOnly","missNaN","type","loc"]; +const KEYS164: readonly string[] = ["kind","fields","nullProto","type","loc"]; +const KEYS165: readonly string[] = ["kind","obj","shapeId","type","loc"]; +const KEYS166: readonly string[] = ["kind","obj","shapeId","key","type","loc"]; +const KEYS167: readonly string[] = ["kind","obj","shapeId","type","loc"]; +const KEYS168: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS169: readonly string[] = ["kind","pattern","flags","type","loc"]; +const KEYS170: readonly string[] = ["kind","type","loc"]; +const KEYS171: readonly string[] = ["kind","stmts","result","generatorDelegate","diverges","type","loc"]; +const KEYS172: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS173: readonly string[] = ["kind","seed","type","loc"]; +const KEYS174: readonly string[] = ["kind","op","left","right","type","loc"]; +const KEYS175: readonly string[] = ["kind","left","right","type","loc"]; +const KEYS176: readonly string[] = ["kind","negated","left","right","type","loc"]; +const KEYS177: readonly string[] = ["kind","method","receiver","args","type","loc"]; +const KEYS178: readonly string[] = ["kind","value","type","loc"]; +const KEYS179: readonly string[] = ["kind","key","cooked","type","loc"]; +const KEYS180: readonly string[] = ["kind","cond","then","else_","type","loc"]; const KEYS181: readonly string[] = ["kind","operand","type","loc"]; -const KEYS182: readonly string[] = ["kind","op","operand","type","loc"]; -const KEYS183: readonly string[] = ["kind","unionId","field","value","type","loc"]; -const KEYS184: readonly string[] = ["kind","unionId","negated","sameValue","left","right","type","loc"]; -const KEYS185: readonly string[] = ["kind","unionId","tag","union","func","negated","type","loc"]; -const KEYS186: readonly string[] = ["kind","unionId","tag","negated","value","type","loc"]; -const KEYS187: readonly string[] = ["kind","unionId","key","value","type","loc"]; -const KEYS188: readonly string[] = ["kind","unionId","tag","value","type","loc"]; -const KEYS189: readonly string[] = ["kind","fromId","unionId","tags","value","type","loc"]; -const KEYS190: readonly string[] = ["kind","unionId","tag","value","type","loc"]; -const KEYS191: readonly string[] = ["kind","unit","type","loc"]; -const KEYS192: readonly string[] = ["kind","value","type","loc"]; -const KEYS193: readonly string[] = ["kind","localId","type","loc"]; -const KEYS194: readonly string[] = ["kind","className","method","args","type","loc","targets"]; -const KEYS195: readonly string[] = ["kind","value","awaited","delegated","captureCompletion","type","loc"]; -const KEYS196: readonly string[] = ["name","type"]; +const KEYS182: readonly string[] = ["kind","operand","type","loc"]; +const KEYS183: readonly string[] = ["kind","op","operand","type","loc"]; +const KEYS184: readonly string[] = ["kind","unionId","field","value","type","loc"]; +const KEYS185: readonly string[] = ["kind","unionId","negated","sameValue","left","right","type","loc"]; +const KEYS186: readonly string[] = ["kind","unionId","tag","union","func","negated","type","loc"]; +const KEYS187: readonly string[] = ["kind","unionId","tag","negated","value","type","loc"]; +const KEYS188: readonly string[] = ["kind","unionId","key","value","type","loc"]; +const KEYS189: readonly string[] = ["kind","unionId","tag","value","type","loc"]; +const KEYS190: readonly string[] = ["kind","fromId","unionId","tags","value","type","loc"]; +const KEYS191: readonly string[] = ["kind","unionId","tag","value","type","loc"]; +const KEYS192: readonly string[] = ["kind","unit","type","loc"]; +const KEYS193: readonly string[] = ["kind","value","type","loc"]; +const KEYS194: readonly string[] = ["kind","localId","type","loc"]; +const KEYS195: readonly string[] = ["kind","className","method","args","type","loc","targets"]; +const KEYS196: readonly string[] = ["kind","value","awaited","delegated","captureCompletion","type","loc"]; const KEYS197: readonly string[] = ["name","type"]; -const KEYS198: readonly string[] = ["path","size","sha256","placeholder"]; -const KEYS199: readonly string[] = ["path","target"]; -const KEYS200: readonly string[] = ["diff","skipPrototype"]; -const KEYS201: readonly string[] = ["returnType"]; -const KEYS202: readonly string[] = ["test","body"]; -const KEYS203: readonly string[] = ["field","globalId"]; -const KEYS204: readonly string[] = ["field","wellKnown"]; -const KEYS205: readonly string[] = ["tag","values"]; -const KEYS206: readonly string[] = ["context"]; -const KEYS207: readonly string[] = ["callback"]; +const KEYS198: readonly string[] = ["name","type"]; +const KEYS199: readonly string[] = ["path","size","sha256","placeholder"]; +const KEYS200: readonly string[] = ["path","target"]; +const KEYS201: readonly string[] = ["diff","skipPrototype"]; +const KEYS202: readonly string[] = ["returnType"]; +const KEYS203: readonly string[] = ["test","body"]; +const KEYS204: readonly string[] = ["field","globalId"]; +const KEYS205: readonly string[] = ["field","wellKnown"]; +const KEYS206: readonly string[] = ["tag","values"]; +const KEYS207: readonly string[] = ["context"]; const KEYS208: readonly string[] = ["callback"]; -const KEYS209: readonly string[] = ["arg","what"]; -const KEYS210: readonly string[] = ["key","value"]; +const KEYS209: readonly string[] = ["callback"]; +const KEYS210: readonly string[] = ["arg","what"]; const KEYS211: readonly string[] = ["key","value"]; -const KEYS212: readonly string[] = ["name","value"]; -const KEYS213: readonly string[] = ["name","value","overflow"]; -const KEYS214: readonly string[] = ["id","params","returns","lifetime","invoke"]; -const KEYS215: readonly string[] = ["release","params","returns"]; +const KEYS212: readonly string[] = ["key","value"]; +const KEYS213: readonly string[] = ["name","value"]; +const KEYS214: readonly string[] = ["name","value","overflow"]; +const KEYS215: readonly string[] = ["id","params","returns","lifetime","invoke"]; +const KEYS216: readonly string[] = ["release","params","returns"]; function encIrModule(w: IrWriter, v: IrModule): void { w.keys(Object.keys(v), KEYS0); @@ -780,91 +781,96 @@ function encIrType(w: IrWriter, v: IrType): void { w.keys(Object.keys(v), KEYS6); return; } - case "bool": { + case "bigintStats": { w.length(2); w.keys(Object.keys(v), KEYS7); return; } - case "bytes": { + case "bool": { w.length(3); w.keys(Object.keys(v), KEYS8); + return; + } + case "bytes": { + w.length(4); + w.keys(Object.keys(v), KEYS9); w.str(v.elem); return; } case "caught": { - w.length(4); - w.keys(Object.keys(v), KEYS9); - return; - } - case "child": { w.length(5); w.keys(Object.keys(v), KEYS10); return; } - case "childStream": { + case "child": { w.length(6); w.keys(Object.keys(v), KEYS11); return; } - case "childWriter": { + case "childStream": { w.length(7); w.keys(Object.keys(v), KEYS12); return; } - case "classval": { + case "childWriter": { w.length(8); w.keys(Object.keys(v), KEYS13); + return; + } + case "classval": { + w.length(9); + w.keys(Object.keys(v), KEYS14); w.str(v.className); return; } case "cryptoHash": { - w.length(9); - w.keys(Object.keys(v), KEYS14); - return; - } - case "cryptoHmac": { w.length(10); w.keys(Object.keys(v), KEYS15); return; } - case "date": { + case "cryptoHmac": { w.length(11); w.keys(Object.keys(v), KEYS16); return; } - case "dgramSocket": { + case "date": { w.length(12); w.keys(Object.keys(v), KEYS17); return; } - case "dir": { + case "dgramSocket": { w.length(13); w.keys(Object.keys(v), KEYS18); return; } - case "dyn": { + case "dir": { w.length(14); w.keys(Object.keys(v), KEYS19); return; } - case "f64": { + case "dyn": { w.length(15); w.keys(Object.keys(v), KEYS20); return; } - case "fileHandle": { + case "f64": { w.length(16); w.keys(Object.keys(v), KEYS21); return; } - case "fsWatcher": { + case "fileHandle": { w.length(17); w.keys(Object.keys(v), KEYS22); return; } - case "func": { + case "fsWatcher": { w.length(18); w.keys(Object.keys(v), KEYS23); + return; + } + case "func": { + w.length(19); + w.keys(Object.keys(v), KEYS24); encT36(w, v.params); encIrType(w, v.ret); if (v.rest === undefined) w.byte(0); @@ -885,8 +891,8 @@ function encIrType(w: IrWriter, v: IrType): void { return; } case "generator": { - w.length(19); - w.keys(Object.keys(v), KEYS24); + w.length(20); + w.keys(Object.keys(v), KEYS25); if (v.async === undefined) w.byte(0); else { w.byte(1); @@ -898,151 +904,151 @@ function encIrType(w: IrWriter, v: IrType): void { return; } case "http2Session": { - w.length(20); - w.keys(Object.keys(v), KEYS25); - return; - } - case "http2Stream": { w.length(21); w.keys(Object.keys(v), KEYS26); return; } - case "httpClientReq": { + case "http2Stream": { w.length(22); w.keys(Object.keys(v), KEYS27); return; } - case "httpReq": { + case "httpClientReq": { w.length(23); w.keys(Object.keys(v), KEYS28); return; } - case "httpRes": { + case "httpReq": { w.length(24); w.keys(Object.keys(v), KEYS29); return; } - case "jsval": { + case "httpRes": { w.length(25); w.keys(Object.keys(v), KEYS30); return; } - case "map": { + case "jsval": { w.length(26); w.keys(Object.keys(v), KEYS31); + return; + } + case "map": { + w.length(27); + w.keys(Object.keys(v), KEYS32); encIrType(w, v.key); encIrType(w, v.value); return; } case "moduleNs": { - w.length(27); - w.keys(Object.keys(v), KEYS32); + w.length(28); + w.keys(Object.keys(v), KEYS33); w.str(v.moduleId); return; } case "netServer": { - w.length(28); - w.keys(Object.keys(v), KEYS33); - return; - } - case "netSocket": { w.length(29); w.keys(Object.keys(v), KEYS34); return; } - case "nullT": { + case "netSocket": { w.length(30); w.keys(Object.keys(v), KEYS35); return; } - case "object": { + case "nullT": { w.length(31); w.keys(Object.keys(v), KEYS36); + return; + } + case "object": { + w.length(32); + w.keys(Object.keys(v), KEYS37); w.str(v.className); return; } case "procStream": { - w.length(32); - w.keys(Object.keys(v), KEYS37); + w.length(33); + w.keys(Object.keys(v), KEYS38); return; } case "promise": { - w.length(33); - w.keys(Object.keys(v), KEYS38); + w.length(34); + w.keys(Object.keys(v), KEYS39); encIrType(w, v.inner); return; } case "record": { - w.length(34); - w.keys(Object.keys(v), KEYS39); + w.length(35); + w.keys(Object.keys(v), KEYS40); w.str(v.shapeId); return; } case "regex": { - w.length(35); - w.keys(Object.keys(v), KEYS40); - return; - } - case "searchParams": { w.length(36); w.keys(Object.keys(v), KEYS41); return; } - case "secureCtx": { + case "searchParams": { w.length(37); w.keys(Object.keys(v), KEYS42); return; } - case "set": { + case "secureCtx": { w.length(38); w.keys(Object.keys(v), KEYS43); + return; + } + case "set": { + w.length(39); + w.keys(Object.keys(v), KEYS44); encIrType(w, v.elem); return; } case "spawnRes": { - w.length(39); - w.keys(Object.keys(v), KEYS44); - return; - } - case "stats": { w.length(40); w.keys(Object.keys(v), KEYS45); return; } - case "string": { + case "stats": { w.length(41); w.keys(Object.keys(v), KEYS46); return; } - case "symbol": { + case "string": { w.length(42); w.keys(Object.keys(v), KEYS47); return; } - case "testCtx": { + case "symbol": { w.length(43); w.keys(Object.keys(v), KEYS48); return; } - case "undefinedT": { + case "testCtx": { w.length(44); w.keys(Object.keys(v), KEYS49); return; } - case "union": { + case "undefinedT": { w.length(45); w.keys(Object.keys(v), KEYS50); + return; + } + case "union": { + w.length(46); + w.keys(Object.keys(v), KEYS51); w.str(v.unionId); return; } case "url": { - w.length(46); - w.keys(Object.keys(v), KEYS51); + w.length(47); + w.keys(Object.keys(v), KEYS52); return; } case "void": { - w.length(47); - w.keys(Object.keys(v), KEYS52); + w.length(48); + w.keys(Object.keys(v), KEYS53); return; } } @@ -1061,72 +1067,76 @@ function decIrType(r: IrReader): IrType { return o; } case 2: { - const o: Extract = { kind: "bool" }; + const o: Extract = { kind: "bigintStats" }; return o; } case 3: { + const o: Extract = { kind: "bool" }; + return o; + } + case 4: { const f0 = (r.str() as NonNullable["elem"]>); const o: Extract = { kind: "bytes", elem: f0 }; return o; } - case 4: { + case 5: { const o: Extract = { kind: "caught" }; return o; } - case 5: { + case 6: { const o: Extract = { kind: "child" }; return o; } - case 6: { + case 7: { const o: Extract = { kind: "childStream" }; return o; } - case 7: { + case 8: { const o: Extract = { kind: "childWriter" }; return o; } - case 8: { + case 9: { const f0 = r.str(); const o: Extract = { kind: "classval", className: f0 }; return o; } - case 9: { + case 10: { const o: Extract = { kind: "cryptoHash" }; return o; } - case 10: { + case 11: { const o: Extract = { kind: "cryptoHmac" }; return o; } - case 11: { + case 12: { const o: Extract = { kind: "date" }; return o; } - case 12: { + case 13: { const o: Extract = { kind: "dgramSocket" }; return o; } - case 13: { + case 14: { const o: Extract = { kind: "dir" }; return o; } - case 14: { + case 15: { const o: Extract = { kind: "dyn" }; return o; } - case 15: { + case 16: { const o: Extract = { kind: "f64" }; return o; } - case 16: { + case 17: { const o: Extract = { kind: "fileHandle" }; return o; } - case 17: { + case 18: { const o: Extract = { kind: "fsWatcher" }; return o; } - case 18: { + case 19: { const f0 = decT36(r); const f1 = decIrType(r); const f2 = r.tag(2, "IrFunction.returnType.rest") === 0 ? undefined : (r.bool() as NonNullable["rest"]>); @@ -1135,7 +1145,7 @@ function decIrType(r: IrReader): IrType { const o: Extract = { kind: "func", params: f0, ret: f1, ...(f2 !== undefined ? { rest: f2 } : {}), ...(f3 !== undefined ? { restAbi: f3 } : {}), ...(f4 !== undefined ? { argumentsAll: f4 } : {}) }; return o; } - case 19: { + case 20: { const f0 = r.tag(2, "IrFunction.returnType.async") === 0 ? undefined : (r.bool() as NonNullable["async"]>); const f1 = decIrType(r); const f2 = decIrType(r); @@ -1143,123 +1153,123 @@ function decIrType(r: IrReader): IrType { const o: Extract = { kind: "generator", ...(f0 !== undefined ? { async: f0 } : {}), yieldT: f1, retT: f2, nextT: f3 }; return o; } - case 20: { + case 21: { const o: Extract = { kind: "http2Session" }; return o; } - case 21: { + case 22: { const o: Extract = { kind: "http2Stream" }; return o; } - case 22: { + case 23: { const o: Extract = { kind: "httpClientReq" }; return o; } - case 23: { + case 24: { const o: Extract = { kind: "httpReq" }; return o; } - case 24: { + case 25: { const o: Extract = { kind: "httpRes" }; return o; } - case 25: { + case 26: { const o: Extract = { kind: "jsval" }; return o; } - case 26: { + case 27: { const f0 = decIrType(r); const f1 = decIrType(r); const o: Extract = { kind: "map", key: f0, value: f1 }; return o; } - case 27: { + case 28: { const f0 = r.str(); const o: Extract = { kind: "moduleNs", moduleId: f0 }; return o; } - case 28: { + case 29: { const o: Extract = { kind: "netServer" }; return o; } - case 29: { + case 30: { const o: Extract = { kind: "netSocket" }; return o; } - case 30: { + case 31: { const o: Extract = { kind: "nullT" }; return o; } - case 31: { + case 32: { const f0 = r.str(); const o: Extract = { kind: "object", className: f0 }; return o; } - case 32: { + case 33: { const o: Extract = { kind: "procStream" }; return o; } - case 33: { + case 34: { const f0 = decIrType(r); const o: Extract = { kind: "promise", inner: f0 }; return o; } - case 34: { + case 35: { const f0 = r.str(); const o: Extract = { kind: "record", shapeId: f0 }; return o; } - case 35: { + case 36: { const o: Extract = { kind: "regex" }; return o; } - case 36: { + case 37: { const o: Extract = { kind: "searchParams" }; return o; } - case 37: { + case 38: { const o: Extract = { kind: "secureCtx" }; return o; } - case 38: { + case 39: { const f0 = decIrType(r); const o: Extract = { kind: "set", elem: f0 }; return o; } - case 39: { + case 40: { const o: Extract = { kind: "spawnRes" }; return o; } - case 40: { + case 41: { const o: Extract = { kind: "stats" }; return o; } - case 41: { + case 42: { const o: Extract = { kind: "string" }; return o; } - case 42: { + case 43: { const o: Extract = { kind: "symbol" }; return o; } - case 43: { + case 44: { const o: Extract = { kind: "testCtx" }; return o; } - case 44: { + case 45: { const o: Extract = { kind: "undefinedT" }; return o; } - case 45: { + case 46: { const f0 = r.str(); const o: Extract = { kind: "union", unionId: f0 }; return o; } - case 46: { + case 47: { const o: Extract = { kind: "url" }; return o; } - case 47: { + case 48: { const o: Extract = { kind: "void" }; return o; } @@ -1307,7 +1317,7 @@ function decT19(r: IrReader): NonNullable { } function encSrcLoc(w: IrWriter, v: SrcLoc): void { - w.keys(Object.keys(v), KEYS53); + w.keys(Object.keys(v), KEYS54); w.file(v.file); w.num(v.start); w.num(v.end); @@ -1321,7 +1331,7 @@ function decSrcLoc(r: IrReader): SrcLoc { } function encT21(w: IrWriter, v: NonNullable): void { - w.keys(Object.keys(v), KEYS54); + w.keys(Object.keys(v), KEYS55); encIrType(w, v.yieldT); encIrType(w, v.nextT); encT40(w, v.resultType); @@ -1353,7 +1363,7 @@ function decT21(r: IrReader): NonNullable { } function encIrClassDef(w: IrWriter, v: IrClassDef): void { - w.keys(Object.keys(v), KEYS55); + w.keys(Object.keys(v), KEYS56); w.str(v.name); if (v.localCaptures === undefined) w.byte(0); else { @@ -1506,7 +1516,7 @@ function decIrClassDef(r: IrReader): IrClassDef { } function encIrGlobal(w: IrWriter, v: IrGlobal): void { - w.keys(Object.keys(v), KEYS56); + w.keys(Object.keys(v), KEYS57); w.str(v.id); w.str(v.name); encIrType(w, v.type); @@ -1540,7 +1550,7 @@ function decIrGlobal(r: IrReader): IrGlobal { } function encIrRecordShape(w: IrWriter, v: IrRecordShape): void { - w.keys(Object.keys(v), KEYS57); + w.keys(Object.keys(v), KEYS58); w.str(v.id); encT44(w, v.fields); if (v.tuple === undefined) w.byte(0); @@ -1588,7 +1598,7 @@ function decIrRecordShape(r: IrReader): IrRecordShape { } function encIrUnionDef(w: IrWriter, v: IrUnionDef): void { - w.keys(Object.keys(v), KEYS58); + w.keys(Object.keys(v), KEYS59); w.str(v.id); encT36(w, v.arms); if (v.discriminant === undefined) w.byte(0); @@ -1606,7 +1616,7 @@ function decIrUnionDef(r: IrReader): IrUnionDef { } function encT26(w: IrWriter, v: NonNullable[number]>): void { - w.keys(Object.keys(v), KEYS59); + w.keys(Object.keys(v), KEYS60); w.file(v.file); w.str(v.pkg); } @@ -1618,7 +1628,7 @@ function decT26(r: IrReader): NonNullable { } function encIrLibIdentity(w: IrWriter, v: IrLibIdentity): void { - w.keys(Object.keys(v), KEYS62); + w.keys(Object.keys(v), KEYS63); w.str(v.buildIdSymbol); w.str(v.abiVersionSymbol); w.str(v.buildId); @@ -1727,7 +1737,7 @@ function decIrLibIdentity(r: IrReader): IrLibIdentity { } function encIrWasmModule(w: IrWriter, v: IrWasmModule): void { - w.keys(Object.keys(v), KEYS63); + w.keys(Object.keys(v), KEYS64); w.str(v.sha256); w.str(v.bytes); } @@ -1739,7 +1749,7 @@ function decIrWasmModule(r: IrReader): IrWasmModule { } function encIrWasmUntranslated(w: IrWriter, v: IrWasmUntranslated): void { - w.keys(Object.keys(v), KEYS64); + w.keys(Object.keys(v), KEYS65); w.str(v.sha256); w.num(v.size); w.str(v.reason); @@ -1753,7 +1763,7 @@ function decIrWasmUntranslated(r: IrReader): IrWasmUntranslated { } function encIrParam(w: IrWriter, v: IrParam): void { - w.keys(Object.keys(v), KEYS65); + w.keys(Object.keys(v), KEYS66); w.str(v.localId); w.str(v.name); encIrType(w, v.type); @@ -1786,7 +1796,7 @@ function decT36(r: IrReader): NonNullable["par } function encIrLocal(w: IrWriter, v: IrLocal): void { - w.keys(Object.keys(v), KEYS66); + w.keys(Object.keys(v), KEYS67); w.str(v.id); w.str(v.name); encIrType(w, v.type); @@ -1820,7 +1830,7 @@ function decIrLocal(r: IrReader): IrLocal { } function encT38(w: IrWriter, v: NonNullable[number]>): void { - w.keys(Object.keys(v), KEYS67); + w.keys(Object.keys(v), KEYS68); w.str(v.localId); w.str(v.name); encIrType(w, v.type); @@ -1845,7 +1855,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { switch (v.kind) { case "arrayDelete": { w.length(0); - w.keys(Object.keys(v), KEYS68); + w.keys(Object.keys(v), KEYS69); encIrExpr(w, v.arr); encIrExpr(w, v.index); encSrcLoc(w, v.loc); @@ -1853,7 +1863,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "arraySet": { w.length(1); - w.keys(Object.keys(v), KEYS69); + w.keys(Object.keys(v), KEYS70); encIrExpr(w, v.arr); encIrExpr(w, v.index); encIrExpr(w, v.value); @@ -1862,7 +1872,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "arraySetLength": { w.length(2); - w.keys(Object.keys(v), KEYS70); + w.keys(Object.keys(v), KEYS71); encIrExpr(w, v.arr); encIrExpr(w, v.length); encSrcLoc(w, v.loc); @@ -1880,7 +1890,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "arraySetUndefined": { w.length(3); - w.keys(Object.keys(v), KEYS71); + w.keys(Object.keys(v), KEYS72); encIrExpr(w, v.arr); encIrExpr(w, v.index); encSrcLoc(w, v.loc); @@ -1888,7 +1898,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "assign": { w.length(4); - w.keys(Object.keys(v), KEYS72); + w.keys(Object.keys(v), KEYS73); w.str(v.localId); encIrExpr(w, v.value); if (v.initializes === undefined) w.byte(0); @@ -1901,7 +1911,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "block": { w.length(5); - w.keys(Object.keys(v), KEYS73); + w.keys(Object.keys(v), KEYS74); if (v.labels === undefined) w.byte(0); else { w.byte(1); @@ -1913,7 +1923,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "break": { w.length(6); - w.keys(Object.keys(v), KEYS74); + w.keys(Object.keys(v), KEYS75); if (v.label === undefined) w.byte(0); else { w.byte(1); @@ -1924,7 +1934,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "bytesSet": { w.length(7); - w.keys(Object.keys(v), KEYS75); + w.keys(Object.keys(v), KEYS76); encIrExpr(w, v.arr); encIrExpr(w, v.index); encIrExpr(w, v.value); @@ -1933,7 +1943,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "continue": { w.length(8); - w.keys(Object.keys(v), KEYS76); + w.keys(Object.keys(v), KEYS77); if (v.label === undefined) w.byte(0); else { w.byte(1); @@ -1944,7 +1954,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "doWhile": { w.length(9); - w.keys(Object.keys(v), KEYS77); + w.keys(Object.keys(v), KEYS78); encT19(w, v.body); encIrExpr(w, v.cond); if (v.labels === undefined) w.byte(0); @@ -1957,14 +1967,14 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "exprStmt": { w.length(10); - w.keys(Object.keys(v), KEYS78); + w.keys(Object.keys(v), KEYS79); encIrExpr(w, v.expr); encSrcLoc(w, v.loc); return; } case "fieldSet": { w.length(11); - w.keys(Object.keys(v), KEYS79); + w.keys(Object.keys(v), KEYS80); encIrExpr(w, v.obj); w.str(v.className); w.str(v.field); @@ -1989,7 +1999,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "for": { w.length(12); - w.keys(Object.keys(v), KEYS80); + w.keys(Object.keys(v), KEYS81); if (v.init === null) w.byte(0); else { w.byte(1); @@ -2016,7 +2026,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "forOf": { w.length(13); - w.keys(Object.keys(v), KEYS81); + w.keys(Object.keys(v), KEYS82); w.str(v.localId); encIrExpr(w, v.iterable); encT19(w, v.body); @@ -2030,7 +2040,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "if": { w.length(14); - w.keys(Object.keys(v), KEYS82); + w.keys(Object.keys(v), KEYS83); encIrExpr(w, v.cond); encT19(w, v.then); if (v.else_ === null) w.byte(0); @@ -2043,7 +2053,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "recordKeyDelete": { w.length(15); - w.keys(Object.keys(v), KEYS83); + w.keys(Object.keys(v), KEYS84); encIrExpr(w, v.obj); w.str(v.shapeId); encIrExpr(w, v.key); @@ -2052,7 +2062,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "recordKeySet": { w.length(16); - w.keys(Object.keys(v), KEYS84); + w.keys(Object.keys(v), KEYS85); encIrExpr(w, v.obj); w.str(v.shapeId); encIrExpr(w, v.key); @@ -2067,7 +2077,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "recordSet": { w.length(17); - w.keys(Object.keys(v), KEYS85); + w.keys(Object.keys(v), KEYS86); encIrExpr(w, v.obj); w.str(v.shapeId); w.str(v.field); @@ -2077,14 +2087,14 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "rethrow": { w.length(18); - w.keys(Object.keys(v), KEYS86); + w.keys(Object.keys(v), KEYS87); w.str(v.localId); encSrcLoc(w, v.loc); return; } case "return": { w.length(19); - w.keys(Object.keys(v), KEYS87); + w.keys(Object.keys(v), KEYS88); if (v.value === null) w.byte(0); else { w.byte(1); @@ -2100,7 +2110,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "runtimeFence": { w.length(20); - w.keys(Object.keys(v), KEYS88); + w.keys(Object.keys(v), KEYS89); w.str(v.code); w.str(v.message); encSrcLoc(w, v.loc); @@ -2108,7 +2118,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "switch": { w.length(21); - w.keys(Object.keys(v), KEYS89); + w.keys(Object.keys(v), KEYS90); encIrExpr(w, v.disc); encT54(w, v.cases); if (v.labels === undefined) w.byte(0); @@ -2121,14 +2131,14 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "throw": { w.length(22); - w.keys(Object.keys(v), KEYS90); + w.keys(Object.keys(v), KEYS91); encIrExpr(w, v.value); encSrcLoc(w, v.loc); return; } case "tryCatch": { w.length(23); - w.keys(Object.keys(v), KEYS91); + w.keys(Object.keys(v), KEYS92); encT19(w, v.tryBody); if (v.catchBody === null) w.byte(0); else { @@ -2160,7 +2170,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "varDecl": { w.length(24); - w.keys(Object.keys(v), KEYS92); + w.keys(Object.keys(v), KEYS93); w.str(v.localId); if (v.init === null) w.byte(0); else { @@ -2172,7 +2182,7 @@ function encIrStmt(w: IrWriter, v: IrStmt): void { } case "while": { w.length(25); - w.keys(Object.keys(v), KEYS93); + w.keys(Object.keys(v), KEYS94); encIrExpr(w, v.cond); encT19(w, v.body); if (v.labels === undefined) w.byte(0); @@ -2399,7 +2409,7 @@ function decIrStmt(r: IrReader): IrStmt { } function encT40(w: IrWriter, v: NonNullable["resultType"]>): void { - w.keys(Object.keys(v), KEYS94); + w.keys(Object.keys(v), KEYS95); w.str(v.kind); w.str(v.shapeId); } @@ -2437,7 +2447,7 @@ function decT42(r: IrReader): NonNullable { } function encIrBindingSource(w: IrWriter, v: IrBindingSource): void { - w.keys(Object.keys(v), KEYS95); + w.keys(Object.keys(v), KEYS96); encSrcLoc(w, v.loc); encSrcLoc(w, v.scope); } @@ -2475,7 +2485,7 @@ function decT45(r: IrReader): NonNullable { } function encIrUnionDiscriminant(w: IrWriter, v: IrUnionDiscriminant): void { - w.keys(Object.keys(v), KEYS96); + w.keys(Object.keys(v), KEYS97); w.str(v.field); encT58(w, v.cases); } @@ -2526,7 +2536,7 @@ function decT49(r: IrReader): NonNullable { } function encIrLibCallback(w: IrWriter, v: IrLibCallback): void { - w.keys(Object.keys(v), KEYS97); + w.keys(Object.keys(v), KEYS98); w.str(v.name); w.num(v.slot); encT62(w, v.params); @@ -2544,7 +2554,7 @@ function decIrLibCallback(r: IrReader): IrLibCallback { } function encIrLibExport(w: IrWriter, v: IrLibExport): void { - w.keys(Object.keys(v), KEYS98); + w.keys(Object.keys(v), KEYS99); w.str(v.symbol); w.str(v.fnName); encT63(w, v.params); @@ -2578,7 +2588,7 @@ function decIrLibExport(r: IrReader): IrLibExport { } function encIrLibTrapOverlay(w: IrWriter, v: IrLibTrapOverlay): void { - w.keys(Object.keys(v), KEYS99); + w.keys(Object.keys(v), KEYS100); w.str(v.code); if (v.teaching === undefined) w.byte(0); else { @@ -2603,7 +2613,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { switch (v.kind) { case "arrIntrinsic": { w.length(0); - w.keys(Object.keys(v), KEYS100); + w.keys(Object.keys(v), KEYS101); w.str(v.method); encIrExpr(w, v.receiver); encT64(w, v.args); @@ -2613,7 +2623,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "arrayGet": { w.length(1); - w.keys(Object.keys(v), KEYS101); + w.keys(Object.keys(v), KEYS102); encIrExpr(w, v.arr); encIrExpr(w, v.index); encIrType(w, v.type); @@ -2622,7 +2632,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "arrayHas": { w.length(2); - w.keys(Object.keys(v), KEYS102); + w.keys(Object.keys(v), KEYS103); encIrExpr(w, v.arr); encIrExpr(w, v.index); encIrType(w, v.type); @@ -2631,7 +2641,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "arrayLit": { w.length(3); - w.keys(Object.keys(v), KEYS103); + w.keys(Object.keys(v), KEYS104); encT64(w, v.elems); if (v.spreads === undefined) w.byte(0); else { @@ -2649,7 +2659,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "arrayNewLen": { w.length(4); - w.keys(Object.keys(v), KEYS104); + w.keys(Object.keys(v), KEYS105); encIrExpr(w, v.length); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2657,7 +2667,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "arrayState": { w.length(5); - w.keys(Object.keys(v), KEYS105); + w.keys(Object.keys(v), KEYS106); encIrExpr(w, v.arr); encIrExpr(w, v.index); encIrType(w, v.type); @@ -2666,7 +2676,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "assignExpr": { w.length(6); - w.keys(Object.keys(v), KEYS106); + w.keys(Object.keys(v), KEYS107); w.str(v.localId); encIrExpr(w, v.value); encIrType(w, v.type); @@ -2675,7 +2685,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "awaitExpr": { w.length(7); - w.keys(Object.keys(v), KEYS107); + w.keys(Object.keys(v), KEYS108); encIrExpr(w, v.value); encIrType(w, v.type); if (v.adopt === undefined) w.byte(0); @@ -2688,7 +2698,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "awaitUnionExpr": { w.length(8); - w.keys(Object.keys(v), KEYS108); + w.keys(Object.keys(v), KEYS109); encIrExpr(w, v.value); w.num(v.promiseTag); encIrType(w, v.type); @@ -2697,7 +2707,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "bin": { w.length(9); - w.keys(Object.keys(v), KEYS109); + w.keys(Object.keys(v), KEYS110); w.str(v.op); encIrExpr(w, v.left); encIrExpr(w, v.right); @@ -2707,7 +2717,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "boolLit": { w.length(10); - w.keys(Object.keys(v), KEYS110); + w.keys(Object.keys(v), KEYS111); w.bool(v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2715,7 +2725,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "bytesIntrinsic": { w.length(11); - w.keys(Object.keys(v), KEYS111); + w.keys(Object.keys(v), KEYS112); w.str(v.method); encIrExpr(w, v.receiver); encT64(w, v.args); @@ -2730,7 +2740,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "bytesNew": { w.length(12); - w.keys(Object.keys(v), KEYS112); + w.keys(Object.keys(v), KEYS113); if (v.source === null) w.byte(0); else { w.byte(1); @@ -2747,7 +2757,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "call": { w.length(13); - w.keys(Object.keys(v), KEYS113); + w.keys(Object.keys(v), KEYS114); w.str(v.callee); encT64(w, v.args); encIrType(w, v.type); @@ -2756,7 +2766,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "callValue": { w.length(14); - w.keys(Object.keys(v), KEYS114); + w.keys(Object.keys(v), KEYS115); encIrExpr(w, v.callee); if (v.receiver === undefined) w.byte(0); else { @@ -2770,7 +2780,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "caughtCheck": { w.length(15); - w.keys(Object.keys(v), KEYS115); + w.keys(Object.keys(v), KEYS116); encIrExpr(w, v.value); w.str(v.className); encIrType(w, v.type); @@ -2779,7 +2789,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "caughtNarrow": { w.length(16); - w.keys(Object.keys(v), KEYS116); + w.keys(Object.keys(v), KEYS117); encIrExpr(w, v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2787,7 +2797,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "caughtTest": { w.length(17); - w.keys(Object.keys(v), KEYS117); + w.keys(Object.keys(v), KEYS118); encIrExpr(w, v.value); w.str(v.test); if (v.className === undefined) w.byte(0); @@ -2806,7 +2816,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "caughtToDyn": { w.length(18); - w.keys(Object.keys(v), KEYS118); + w.keys(Object.keys(v), KEYS119); encIrExpr(w, v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2814,7 +2824,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "chainRecv": { w.length(19); - w.keys(Object.keys(v), KEYS119); + w.keys(Object.keys(v), KEYS120); w.str(v.id); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2822,7 +2832,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "classRef": { w.length(20); - w.keys(Object.keys(v), KEYS120); + w.keys(Object.keys(v), KEYS121); w.str(v.className); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2835,7 +2845,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "closure": { w.length(21); - w.keys(Object.keys(v), KEYS121); + w.keys(Object.keys(v), KEYS122); w.str(v.fnName); encT8(w, v.captures); if (v.adapts === undefined) w.byte(0); @@ -2849,7 +2859,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "downcast": { w.length(22); - w.keys(Object.keys(v), KEYS122); + w.keys(Object.keys(v), KEYS123); encIrExpr(w, v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2857,7 +2867,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynArrLit": { w.length(23); - w.keys(Object.keys(v), KEYS123); + w.keys(Object.keys(v), KEYS124); encT64(w, v.elems); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2865,7 +2875,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynCall": { w.length(24); - w.keys(Object.keys(v), KEYS124); + w.keys(Object.keys(v), KEYS125); encIrExpr(w, v.callee); if (v.receiver === undefined) w.byte(0); else { @@ -2890,7 +2900,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynCheck": { w.length(25); - w.keys(Object.keys(v), KEYS125); + w.keys(Object.keys(v), KEYS126); encIrExpr(w, v.value); if (v.preserveRefs === undefined) w.byte(0); else { @@ -2923,7 +2933,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynDestrCheck": { w.length(26); - w.keys(Object.keys(v), KEYS126); + w.keys(Object.keys(v), KEYS127); encIrExpr(w, v.value); w.str(v.spelling); if (v.firstProp === undefined) w.byte(0); @@ -2937,7 +2947,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynFrom": { w.length(27); - w.keys(Object.keys(v), KEYS127); + w.keys(Object.keys(v), KEYS128); encIrExpr(w, v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -2960,7 +2970,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynHasKey": { w.length(28); - w.keys(Object.keys(v), KEYS128); + w.keys(Object.keys(v), KEYS129); w.str(v.key); if (v.negated === undefined) w.byte(0); else { @@ -2974,7 +2984,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynInvoke": { w.length(29); - w.keys(Object.keys(v), KEYS129); + w.keys(Object.keys(v), KEYS130); encIrExpr(w, v.recv); w.str(v.method); w.str(v.calleeName); @@ -2990,7 +3000,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynIterN": { w.length(30); - w.keys(Object.keys(v), KEYS130); + w.keys(Object.keys(v), KEYS131); encIrExpr(w, v.value); w.num(v.count); if (v.notIterableMessage === undefined) w.byte(0); @@ -3004,7 +3014,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynKeyGet": { w.length(31); - w.keys(Object.keys(v), KEYS131); + w.keys(Object.keys(v), KEYS132); encIrExpr(w, v.key); if (v.optional === undefined) w.byte(0); else { @@ -3018,7 +3028,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynObjLit": { w.length(32); - w.keys(Object.keys(v), KEYS132); + w.keys(Object.keys(v), KEYS133); if (v.fields === undefined) w.byte(0); else { w.byte(1); @@ -3030,7 +3040,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynScalarEq": { w.length(33); - w.keys(Object.keys(v), KEYS133); + w.keys(Object.keys(v), KEYS134); encIrExpr(w, v.left); encIrExpr(w, v.right); if (v.negated === undefined) w.byte(0); @@ -3044,7 +3054,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "dynTest": { w.length(34); - w.keys(Object.keys(v), KEYS134); + w.keys(Object.keys(v), KEYS135); w.str(v.test); if (v.bytesElem === undefined) w.byte(0); else { @@ -3063,7 +3073,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "ffiCall": { w.length(35); - w.keys(Object.keys(v), KEYS135); + w.keys(Object.keys(v), KEYS136); w.str(v.import); encT64(w, v.args); encIrType(w, v.type); @@ -3072,7 +3082,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "fieldAbsent": { w.length(36); - w.keys(Object.keys(v), KEYS136); + w.keys(Object.keys(v), KEYS137); w.str(v.unionId); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3080,7 +3090,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "fieldGet": { w.length(37); - w.keys(Object.keys(v), KEYS137); + w.keys(Object.keys(v), KEYS138); encIrExpr(w, v.obj); w.str(v.className); w.str(v.field); @@ -3090,7 +3100,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "fieldIncDec": { w.length(38); - w.keys(Object.keys(v), KEYS138); + w.keys(Object.keys(v), KEYS139); w.str(v.op); w.bool(v.prefix); encIrExpr(w, v.obj); @@ -3103,7 +3113,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "genResume": { w.length(39); - w.keys(Object.keys(v), KEYS139); + w.keys(Object.keys(v), KEYS140); w.str(v.mode); encIrExpr(w, v.gen); if (v.arg === null) w.byte(0); @@ -3117,7 +3127,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "incDec": { w.length(40); - w.keys(Object.keys(v), KEYS140); + w.keys(Object.keys(v), KEYS141); w.str(v.op); w.bool(v.prefix); w.str(v.localId); @@ -3127,7 +3137,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "instanceOf": { w.length(41); - w.keys(Object.keys(v), KEYS141); + w.keys(Object.keys(v), KEYS142); encIrExpr(w, v.value); w.str(v.className); encIrType(w, v.type); @@ -3136,7 +3146,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "instanceOfValue": { w.length(42); - w.keys(Object.keys(v), KEYS142); + w.keys(Object.keys(v), KEYS143); encIrExpr(w, v.value); encIrExpr(w, v.classValue); encIrType(w, v.type); @@ -3145,7 +3155,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "intrinsic": { w.length(43); - w.keys(Object.keys(v), KEYS143); + w.keys(Object.keys(v), KEYS144); w.str(v.name); encT64(w, v.args); encIrType(w, v.type); @@ -3154,7 +3164,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "jsonStringify": { w.length(44); - w.keys(Object.keys(v), KEYS144); + w.keys(Object.keys(v), KEYS145); encIrExpr(w, v.value); if (v.indent === undefined) w.byte(0); else { @@ -3172,7 +3182,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "libCall": { w.length(45); - w.keys(Object.keys(v), KEYS145); + w.keys(Object.keys(v), KEYS146); w.str(v.fn); encT64(w, v.args); encIrType(w, v.type); @@ -3196,7 +3206,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "logical": { w.length(46); - w.keys(Object.keys(v), KEYS146); + w.keys(Object.keys(v), KEYS147); w.str(v.op); encIrExpr(w, v.left); encIrExpr(w, v.right); @@ -3206,7 +3216,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "mapIntrinsic": { w.length(47); - w.keys(Object.keys(v), KEYS147); + w.keys(Object.keys(v), KEYS148); w.str(v.method); encIrExpr(w, v.receiver); encT64(w, v.args); @@ -3216,7 +3226,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "mapNew": { w.length(48); - w.keys(Object.keys(v), KEYS148); + w.keys(Object.keys(v), KEYS149); if (v.seed === undefined) w.byte(0); else { w.byte(1); @@ -3228,7 +3238,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "moduleNsRef": { w.length(49); - w.keys(Object.keys(v), KEYS149); + w.keys(Object.keys(v), KEYS150); w.str(v.moduleId); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3236,7 +3246,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "new": { w.length(50); - w.keys(Object.keys(v), KEYS150); + w.keys(Object.keys(v), KEYS151); w.str(v.className); encT64(w, v.args); encIrType(w, v.type); @@ -3245,7 +3255,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "newPromise": { w.length(51); - w.keys(Object.keys(v), KEYS151); + w.keys(Object.keys(v), KEYS152); encIrExpr(w, v.executor); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3253,7 +3263,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "newValue": { w.length(52); - w.keys(Object.keys(v), KEYS152); + w.keys(Object.keys(v), KEYS153); encIrExpr(w, v.callee); encT64(w, v.args); encIrType(w, v.type); @@ -3262,7 +3272,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "nullish": { w.length(53); - w.keys(Object.keys(v), KEYS153); + w.keys(Object.keys(v), KEYS154); encIrExpr(w, v.left); encIrExpr(w, v.right); encIrType(w, v.type); @@ -3271,7 +3281,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "numLit": { w.length(54); - w.keys(Object.keys(v), KEYS154); + w.keys(Object.keys(v), KEYS155); w.num(v.value); if (v.spelling === undefined) w.byte(0); else { @@ -3284,7 +3294,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "optChain": { w.length(55); - w.keys(Object.keys(v), KEYS155); + w.keys(Object.keys(v), KEYS156); w.str(v.id); encIrExpr(w, v.receiver); encIrExpr(w, v.body); @@ -3294,7 +3304,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "orDefault": { w.length(56); - w.keys(Object.keys(v), KEYS156); + w.keys(Object.keys(v), KEYS157); encIrExpr(w, v.left); encIrExpr(w, v.right); if (v.retag === undefined) w.byte(0); @@ -3308,7 +3318,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "promiseVoidWiden": { w.length(57); - w.keys(Object.keys(v), KEYS157); + w.keys(Object.keys(v), KEYS158); encIrExpr(w, v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3316,14 +3326,14 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "promiseWithResolvers": { w.length(58); - w.keys(Object.keys(v), KEYS158); + w.keys(Object.keys(v), KEYS159); encIrType(w, v.type); encSrcLoc(w, v.loc); return; } case "recordClone": { w.length(59); - w.keys(Object.keys(v), KEYS159); + w.keys(Object.keys(v), KEYS160); encIrExpr(w, v.source); encT70(w, v.overrides); encIrType(w, v.type); @@ -3332,7 +3342,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordGet": { w.length(60); - w.keys(Object.keys(v), KEYS160); + w.keys(Object.keys(v), KEYS161); encIrExpr(w, v.obj); w.str(v.shapeId); w.str(v.field); @@ -3342,7 +3352,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordHas": { w.length(61); - w.keys(Object.keys(v), KEYS161); + w.keys(Object.keys(v), KEYS162); encIrExpr(w, v.obj); w.str(v.shapeId); w.str(v.field); @@ -3352,7 +3362,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordKeyGet": { w.length(62); - w.keys(Object.keys(v), KEYS162); + w.keys(Object.keys(v), KEYS163); encIrExpr(w, v.obj); w.str(v.shapeId); encIrExpr(w, v.key); @@ -3372,7 +3382,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordLit": { w.length(63); - w.keys(Object.keys(v), KEYS163); + w.keys(Object.keys(v), KEYS164); encT71(w, v.fields); if (v.nullProto === undefined) w.byte(0); else { @@ -3385,7 +3395,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordNullProto": { w.length(64); - w.keys(Object.keys(v), KEYS164); + w.keys(Object.keys(v), KEYS165); encIrExpr(w, v.obj); w.str(v.shapeId); encIrType(w, v.type); @@ -3394,7 +3404,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordOvfHas": { w.length(65); - w.keys(Object.keys(v), KEYS165); + w.keys(Object.keys(v), KEYS166); encIrExpr(w, v.obj); w.str(v.shapeId); encIrExpr(w, v.key); @@ -3404,7 +3414,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "recordOvfKeys": { w.length(66); - w.keys(Object.keys(v), KEYS166); + w.keys(Object.keys(v), KEYS167); encIrExpr(w, v.obj); w.str(v.shapeId); encIrType(w, v.type); @@ -3413,7 +3423,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "regexIntrinsic": { w.length(67); - w.keys(Object.keys(v), KEYS167); + w.keys(Object.keys(v), KEYS168); w.str(v.method); encIrExpr(w, v.receiver); encT64(w, v.args); @@ -3423,7 +3433,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "regexLit": { w.length(68); - w.keys(Object.keys(v), KEYS168); + w.keys(Object.keys(v), KEYS169); w.str(v.pattern); w.str(v.flags); encIrType(w, v.type); @@ -3432,14 +3442,14 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "selfRef": { w.length(69); - w.keys(Object.keys(v), KEYS169); + w.keys(Object.keys(v), KEYS170); encIrType(w, v.type); encSrcLoc(w, v.loc); return; } case "seqExpr": { w.length(70); - w.keys(Object.keys(v), KEYS170); + w.keys(Object.keys(v), KEYS171); encT19(w, v.stmts); encIrExpr(w, v.result); if (v.generatorDelegate === undefined) w.byte(0); @@ -3458,7 +3468,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "setIntrinsic": { w.length(71); - w.keys(Object.keys(v), KEYS171); + w.keys(Object.keys(v), KEYS172); w.str(v.method); encIrExpr(w, v.receiver); encT64(w, v.args); @@ -3468,7 +3478,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "setNew": { w.length(72); - w.keys(Object.keys(v), KEYS172); + w.keys(Object.keys(v), KEYS173); if (v.seed === undefined) w.byte(0); else { w.byte(1); @@ -3480,7 +3490,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "strCmp": { w.length(73); - w.keys(Object.keys(v), KEYS173); + w.keys(Object.keys(v), KEYS174); w.str(v.op); encIrExpr(w, v.left); encIrExpr(w, v.right); @@ -3490,7 +3500,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "strConcat": { w.length(74); - w.keys(Object.keys(v), KEYS174); + w.keys(Object.keys(v), KEYS175); encIrExpr(w, v.left); encIrExpr(w, v.right); encIrType(w, v.type); @@ -3499,7 +3509,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "strEq": { w.length(75); - w.keys(Object.keys(v), KEYS175); + w.keys(Object.keys(v), KEYS176); w.bool(v.negated); encIrExpr(w, v.left); encIrExpr(w, v.right); @@ -3509,7 +3519,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "strIntrinsic": { w.length(76); - w.keys(Object.keys(v), KEYS176); + w.keys(Object.keys(v), KEYS177); w.str(v.method); encIrExpr(w, v.receiver); encT64(w, v.args); @@ -3519,7 +3529,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "strLit": { w.length(77); - w.keys(Object.keys(v), KEYS177); + w.keys(Object.keys(v), KEYS178); w.str(v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3527,7 +3537,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "templateStrings": { w.length(78); - w.keys(Object.keys(v), KEYS178); + w.keys(Object.keys(v), KEYS179); w.str(v.key); encT8(w, v.cooked); encIrType(w, v.type); @@ -3536,7 +3546,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "ternary": { w.length(79); - w.keys(Object.keys(v), KEYS179); + w.keys(Object.keys(v), KEYS180); encIrExpr(w, v.cond); encIrExpr(w, v.then); encIrExpr(w, v.else_); @@ -3546,7 +3556,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "toBool": { w.length(80); - w.keys(Object.keys(v), KEYS180); + w.keys(Object.keys(v), KEYS181); encIrExpr(w, v.operand); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3554,7 +3564,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "toString": { w.length(81); - w.keys(Object.keys(v), KEYS181); + w.keys(Object.keys(v), KEYS182); encIrExpr(w, v.operand); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3562,7 +3572,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unary": { w.length(82); - w.keys(Object.keys(v), KEYS182); + w.keys(Object.keys(v), KEYS183); w.str(v.op); encIrExpr(w, v.operand); encIrType(w, v.type); @@ -3571,7 +3581,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionDisc": { w.length(83); - w.keys(Object.keys(v), KEYS183); + w.keys(Object.keys(v), KEYS184); w.str(v.unionId); w.str(v.field); encIrExpr(w, v.value); @@ -3581,7 +3591,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionEq": { w.length(84); - w.keys(Object.keys(v), KEYS184); + w.keys(Object.keys(v), KEYS185); w.str(v.unionId); w.bool(v.negated); w.bool(v.sameValue); @@ -3593,7 +3603,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionFuncEq": { w.length(85); - w.keys(Object.keys(v), KEYS185); + w.keys(Object.keys(v), KEYS186); w.str(v.unionId); w.num(v.tag); encIrExpr(w, v.union); @@ -3605,7 +3615,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionIsTag": { w.length(86); - w.keys(Object.keys(v), KEYS186); + w.keys(Object.keys(v), KEYS187); w.str(v.unionId); w.num(v.tag); w.bool(v.negated); @@ -3616,7 +3626,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionKeyGet": { w.length(87); - w.keys(Object.keys(v), KEYS187); + w.keys(Object.keys(v), KEYS188); w.str(v.unionId); encIrExpr(w, v.key); encIrExpr(w, v.value); @@ -3626,7 +3636,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionNarrow": { w.length(88); - w.keys(Object.keys(v), KEYS188); + w.keys(Object.keys(v), KEYS189); w.str(v.unionId); w.num(v.tag); encIrExpr(w, v.value); @@ -3636,7 +3646,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionRetype": { w.length(89); - w.keys(Object.keys(v), KEYS189); + w.keys(Object.keys(v), KEYS190); w.str(v.fromId); w.str(v.unionId); encT65(w, v.tags); @@ -3647,7 +3657,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unionWrap": { w.length(90); - w.keys(Object.keys(v), KEYS190); + w.keys(Object.keys(v), KEYS191); w.str(v.unionId); w.num(v.tag); encIrExpr(w, v.value); @@ -3657,7 +3667,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "unitLit": { w.length(91); - w.keys(Object.keys(v), KEYS191); + w.keys(Object.keys(v), KEYS192); w.str(v.unit); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3665,7 +3675,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "upcast": { w.length(92); - w.keys(Object.keys(v), KEYS192); + w.keys(Object.keys(v), KEYS193); encIrExpr(w, v.value); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3673,7 +3683,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "varRef": { w.length(93); - w.keys(Object.keys(v), KEYS193); + w.keys(Object.keys(v), KEYS194); w.str(v.localId); encIrType(w, v.type); encSrcLoc(w, v.loc); @@ -3681,7 +3691,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "virtualCall": { w.length(94); - w.keys(Object.keys(v), KEYS194); + w.keys(Object.keys(v), KEYS195); w.str(v.className); w.str(v.method); encT64(w, v.args); @@ -3696,7 +3706,7 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { } case "yieldExpr": { w.length(95); - w.keys(Object.keys(v), KEYS195); + w.keys(Object.keys(v), KEYS196); if (v.value === null) w.byte(0); else { w.byte(1); @@ -4587,7 +4597,7 @@ function decT55(r: IrReader): NonNullable[number]>): void { - w.keys(Object.keys(v), KEYS196); + w.keys(Object.keys(v), KEYS197); w.str(v.name); encIrType(w, v.type); } @@ -4599,7 +4609,7 @@ function decT56(r: IrReader): NonNullable[numb } function encT57(w: IrWriter, v: NonNullable[number]>): void { - w.keys(Object.keys(v), KEYS197); + w.keys(Object.keys(v), KEYS198); w.str(v.name); encIrType(w, v.type); } @@ -4624,7 +4634,7 @@ function decT58(r: IrReader): NonNullable { } function encT59(w: IrWriter, v: NonNullable[number]>): void { - w.keys(Object.keys(v), KEYS198); + w.keys(Object.keys(v), KEYS199); w.str(v.path); w.num(v.size); w.str(v.sha256); @@ -4644,7 +4654,7 @@ function decT59(r: IrReader): NonNullable[number]>): void { - w.keys(Object.keys(v), KEYS199); + w.keys(Object.keys(v), KEYS200); w.str(v.path); w.str(v.target); } @@ -4771,7 +4781,7 @@ function decT67(r: IrReader): NonNullable } function encT68(w: IrWriter, v: NonNullable["assertOptions"]>): void { - w.keys(Object.keys(v), KEYS200); + w.keys(Object.keys(v), KEYS201); if (v.diff === undefined) w.byte(0); else { w.byte(1); @@ -4830,7 +4840,7 @@ function decT71(r: IrReader): NonNullable } function encT72(w: IrWriter, v: NonNullable["captureCompletion"]>): void { - w.keys(Object.keys(v), KEYS201); + w.keys(Object.keys(v), KEYS202); encIrType(w, v.returnType); } function decT72(r: IrReader): NonNullable["captureCompletion"]> { @@ -4840,7 +4850,7 @@ function decT72(r: IrReader): NonNullable } function encT73(w: IrWriter, v: NonNullable["cases"]>[number]>): void { - w.keys(Object.keys(v), KEYS202); + w.keys(Object.keys(v), KEYS203); if (v.test === null) w.byte(0); else { w.byte(1); @@ -4856,7 +4866,7 @@ function decT73(r: IrReader): NonNullable[number]>, { globalId: unknown }>): void { - w.keys(Object.keys(v), KEYS203); + w.keys(Object.keys(v), KEYS204); w.str(v.field); w.str(v.globalId); } @@ -4868,7 +4878,7 @@ function decT74(r: IrReader): Extract[number]>, { wellKnown: unknown }>): void { - w.keys(Object.keys(v), KEYS204); + w.keys(Object.keys(v), KEYS205); w.str(v.field); w.str(v.wellKnown); } @@ -4880,7 +4890,7 @@ function decT75(r: IrReader): Extract[number]>): void { - w.keys(Object.keys(v), KEYS205); + w.keys(Object.keys(v), KEYS206); w.num(v.tag); encT85(w, v.values); } @@ -4892,7 +4902,7 @@ function decT76(r: IrReader): NonNullable["spreads"]>[number]>): void { - w.keys(Object.keys(v), KEYS209); + w.keys(Object.keys(v), KEYS210); w.num(v.arg); w.str(v.what); } @@ -4934,7 +4944,7 @@ function decT80(r: IrReader): NonNullable["fields"]>[number]>): void { - w.keys(Object.keys(v), KEYS210); + w.keys(Object.keys(v), KEYS211); encIrExpr(w, v.key); encIrExpr(w, v.value); } @@ -4946,7 +4956,7 @@ function decT81(r: IrReader): NonNullable["seed"]>[number]>): void { - w.keys(Object.keys(v), KEYS211); + w.keys(Object.keys(v), KEYS212); encIrExpr(w, v.key); encIrExpr(w, v.value); } @@ -4958,7 +4968,7 @@ function decT82(r: IrReader): NonNullable["overrides"]>[number]>): void { - w.keys(Object.keys(v), KEYS212); + w.keys(Object.keys(v), KEYS213); w.str(v.name); encIrExpr(w, v.value); } @@ -4970,7 +4980,7 @@ function decT83(r: IrReader): NonNullable["fields"]>[number]>): void { - w.keys(Object.keys(v), KEYS213); + w.keys(Object.keys(v), KEYS214); w.str(v.name); encIrExpr(w, v.value); if (v.overflow === undefined) w.byte(0); @@ -5001,7 +5011,7 @@ function decT85(r: IrReader): NonNullable): void { - w.keys(Object.keys(v), KEYS214); + w.keys(Object.keys(v), KEYS215); w.str(v.id); encT89(w, v.params); w.str(v.returns); @@ -5019,7 +5029,7 @@ function decT86(r: IrReader): NonNullable { } function encT87(w: IrWriter, v: NonNullable): void { - w.keys(Object.keys(v), KEYS215); + w.keys(Object.keys(v), KEYS216); w.str(v.release); encT89(w, v.params); w.str(v.returns); diff --git a/packages/compiler/src/ir/codec.test.ts b/packages/compiler/src/ir/codec.test.ts index 538bd85de..ce443af52 100644 --- a/packages/compiler/src/ir/codec.test.ts +++ b/packages/compiler/src/ir/codec.test.ts @@ -17,7 +17,7 @@ import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js"; import { deserializeModuleBytes } from "./serialize.js"; // Programs with many node kinds: classes, closures, async, generators, -// dynamic values, streams, records, regexes and lone surrogates. +// dynamic values, streams, records, regexes, lone surrogates and BigIntStats. const PROGRAMS = [ "1003-json-parse-unions.ts", "114-live-record-delete.ts", @@ -26,6 +26,7 @@ const PROGRAMS = [ "4900-console-instances.ts", "4991-lone-surrogate-halves.ts", "5612-readable-from-iterables.cjs", + "7018-bigint-stats.ts", "7110-als-completion-callbacks.ts", "7321-speculative-loop-bail-sites.ts", ]; diff --git a/packages/compiler/test/unit-reuse.test.ts b/packages/compiler/test/unit-reuse.test.ts index 80393cf0f..da79ced88 100644 --- a/packages/compiler/test/unit-reuse.test.ts +++ b/packages/compiler/test/unit-reuse.test.ts @@ -313,3 +313,68 @@ test("the first build into a cache writes the declaration tables and no unit rec await workspace.dispose(); } }, 300_000); + +const ALIASES = { + "values.ts": [ + "export const limit: number = 3;", + "export const unit = 2;", + "export default function scale(value: number): number {", + " return value * unit;", + "}", + "export { unit as step };", + "", + ].join("\n"), + "main.ts": [ + 'import { limit as max, step } from "./values.js";', + 'import grow from "./values.js";', + "function cap(value: number): string {", + " return `${value} of ${max}`;", + "}", + "function stepped(value: number): number {", + " return value + step;", + "}", + "function grown(value: number): number {", + " return grow(value);", + "}", + "console.log(cap(5), stepped(1), grown(4));", + "", + ].join("\n"), +}; + +test("a renamed import lowers again when its declaration's surface changes", async () => { + const workspace = await Workspace.create(ALIASES); + try { + await workspace.build(); + await workspace.edit( + "values.ts", + "export const limit: number = 3;", + 'export const limit = "3";', + ); + const applied = await workspace.build(); + expect(has(applied, "cap")).toBe(false); + expect(has(applied, "grown")).toBe(true); + } finally { + await workspace.dispose(); + } +}, 300_000); + +test("an import of a renamed export or of a default export lowers again when its declaration changes", async () => { + const workspace = await Workspace.create(ALIASES); + try { + await workspace.build(); + await workspace.edit("values.ts", "export const unit = 2;", "export const unit = 5;"); + let applied = await workspace.build(); + expect(has(applied, "stepped")).toBe(false); + expect(has(applied, "cap")).toBe(true); + await workspace.edit( + "values.ts", + "export default function scale(value: number): number {", + "export default function scale(value: number, by = 1): number {", + ); + applied = await workspace.build(); + expect(has(applied, "grown")).toBe(false); + expect(has(applied, "cap")).toBe(true); + } finally { + await workspace.dispose(); + } +}, 300_000); From 7d1f9580eeebf3b73350575bd4f9cfe8deb51967 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:55:52 -0500 Subject: [PATCH 061/193] Release path module members through the object core's accessors The exit-time cleanup of the path module objects swapped each member value out of the object's storage by reading v.obj.slots and v.obj.entries directly. It now goes through scr_dyn_obj_count and a new scr_dyn.h accessor, scr_dyn_obj_exchange_value_at, which stores a value at an index without reading the keys, so the cleanup still runs after the exit-time layout reset. --- packages/runtime/src/scr_dyn.h | 11 +++++++++++ packages/runtime/src/scr_node_builtin.c | 12 ++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 47f9046b6..839e654f0 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -770,6 +770,17 @@ static inline __attribute__((always_inline)) ScrDynProp scr_dyn_obj_prop_at(cons e->accessor, e->writable, e->enumerable, e->configurable}; } +/* Stores `value` (moved) as the value of data property i and returns the + * previous value (owned by the caller). Reads no keys and does not follow + * the layout pointer, so exit-time teardown can use it after the layouts + * are freed. */ +static inline ScrDyn *scr_dyn_obj_exchange_value_at(ScrDyn *o, size_t i, ScrDyn *value) { + ScrDyn **slot = o->v.obj.layout ? &o->v.obj.slots[i] : &o->v.obj.entries[i].value; + ScrDyn *previous = *slot; + *slot = value; + return previous; +} + /* The index of own property `key`, or SIZE_MAX. A linear scan in * dictionary mode: for code that looks up options and headers, not for hot * property reads. */ diff --git a/packages/runtime/src/scr_node_builtin.c b/packages/runtime/src/scr_node_builtin.c index 71b523742..d312a6e80 100644 --- a/packages/runtime/src/scr_node_builtin.c +++ b/packages/runtime/src/scr_node_builtin.c @@ -368,18 +368,14 @@ static void scr_builtin_release(void *handle) { static void scr_builtin_cleanup(void) { /* An exports object can hold itself and the other path family * (path.posix.posix, path.posix.win32): its member values are released - * first, so the objects are freed. The values are swapped out of the - * object's own storage, without reading its keys: the exit-time layout + * first, so the objects are freed. The values are swapped out without + * reading the keys (scr_dyn_obj_exchange_value_at): the exit-time layout * reset can run before this and free the layout the keys live in. */ for (ScrBuiltinModule *module = scr_builtin_modules; module; module = module->next) { if (!module->object) continue; ScrDyn *exports = module->exports; - for (size_t i = 0; i < exports->v.obj.len; i++) { - ScrDyn **slot = exports->v.obj.layout ? &exports->v.obj.slots[i] : &exports->v.obj.entries[i].value; - ScrDyn *value = *slot; - *slot = scr_dyn_retain(scr_dyn_undefined()); - scr_dyn_release(value); - } + for (size_t i = 0; i < scr_dyn_obj_count(exports); i++) + scr_dyn_release(scr_dyn_obj_exchange_value_at(exports, i, scr_dyn_retain(scr_dyn_undefined()))); } while (scr_builtin_modules) { ScrBuiltinModule *module = scr_builtin_modules; From 96739536d2d30a9ce450ddb593efc8456e639ec6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:22:18 -0500 Subject: [PATCH 062/193] Read fs.read's position option once and check a bigint against length |= 0 fs.read's options forms read the position option a second time when it was not a bigint, so a getter ran twice and a stateful one could move the read. The option is now read once. A bigint position was checked against the length before `length |= 0`. A fractional or infinite length made the range check build its bound from a null BigInt and crash. The check and the read now take the converted length, so such a read completes with zero or the truncated count of bytes, as in Node. --- packages/runtime/src/scr_bytes_io.c | 45 ++++++++++++------------ tests/corpus/fs-read-bigint-position.cjs | 37 +++++++++++++++++-- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 035795619..0d3156c26 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -167,6 +167,7 @@ static ScrDyn *scr_fs_cb_arg(ScrDyn *const *args, size_t argc, size_t index) { } static bool scr_fs_bigint_position_chk(ScrBigInt *position, double length); +static double scr_fs_int32(double n); static ScrDyn *scr_fs_cb_option(const ScrDyn *options, const char *name) { if (scr_fs_dyn_absent(options)) return scr_dyn_retain(scr_dyn_undefined()); @@ -1131,30 +1132,28 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc if (ownedBuffer) length = scr_fs_cb_option_number(options, "length", length); else if (readOp && count >= 4) length = readLength; else length = scr_fs_cb_number(ARG(3), "length", length); - /* A bigint read position takes readSync's range check and reads at - * its exact value: a double would round it past 2^53. */ - ScrDyn *bigPosition = NULL; - if (readOp) { - ScrDyn *value = ownedBuffer ? scr_fs_cb_option(options, "position") - : scr_dyn_retain(count > 4 ? ARG(4) : scr_dyn_undefined()); - if (value && value->kind == SCR_DYN_BIGINT) bigPosition = value; - else scr_dyn_release(value); - } - double position = bigPosition ? -1 - : ownedBuffer ? scr_fs_cb_option_number(options, "position", -1) - : scr_fs_cb_number(ARG(4), "position", -1); + /* The position option is read once, so a getter runs once. A bigint + * read position takes readSync's range check against `length |= 0` + * and reads at its exact value: a double would round it past 2^53. */ + ScrDyn *positionValue = ownedBuffer ? scr_fs_cb_option(options, "position") + : scr_dyn_retain(ARG(4)); + bool bigPosition = readOp && positionValue && positionValue->kind == SCR_DYN_BIGINT; + double position = bigPosition || !positionValue + ? -1 + : scr_fs_cb_number(positionValue, "position", -1); if (!scr_exc_pending()) { if (bigPosition) { + double count32 = scr_fs_int32(length); if (scr_fs_read_offset_chk(offset) && - scr_fs_bigint_position_chk(bigPosition->v.bigint, length)) - result = scr_dyn_new_num(scr_fs_read_sync_at(fd, bytes, offset, length, - scr_bigint_to_i64(bigPosition->v.bigint))); + scr_fs_bigint_position_chk(positionValue->v.bigint, count32)) + result = scr_dyn_new_num(scr_fs_read_sync_at(fd, bytes, offset, count32, + scr_bigint_to_i64(positionValue->v.bigint))); } else { result = scr_dyn_new_num(readOp ? scr_fs_read_sync(fd, bytes, offset, length, position) : scr_fs_write_sync(fd, bytes, offset, length, position)); } } - scr_dyn_release(bigPosition); + scr_dyn_release(positionValue); } scr_bytes_release(bytes); /* The completion retains the original buffer, including options.buffer. */ @@ -1938,14 +1937,16 @@ static bool scr_fs_length_int32(const ScrDyn *length, double *out) { } else if (!scr_dyn_number_coerce_js(length, &n)) { return false; } - if (!isfinite(n)) { - *out = 0; - return true; - } + *out = scr_fs_int32(n); + return true; +} + +/* ToInt32 of a number: 0 for NaN and the infinities. */ +static double scr_fs_int32(double n) { + if (!isfinite(n)) return 0; double m = fmod(trunc(n), 4294967296.0); if (m < 0) m += 4294967296.0; - *out = m >= 2147483648.0 ? m - 4294967296.0 : m; - return true; + return m >= 2147483648.0 ? m - 4294967296.0 : m; } /* A bigint read position in [-1n, 2n ** 63n - 1n - BigInt(length)] diff --git a/tests/corpus/fs-read-bigint-position.cjs b/tests/corpus/fs-read-bigint-position.cjs index b54f331cf..424506491 100644 --- a/tests/corpus/fs-read-bigint-position.cjs +++ b/tests/corpus/fs-read-bigint-position.cjs @@ -1,6 +1,8 @@ // fs.read's callback forms with bigint positions read at the exact // position, including past 2^53 (beyond the file's end, so nothing is -// read), and -1n reads at the current offset. +// read), and -1n reads at the current offset. A fractional or infinite +// length takes `length |= 0` before the position check, and a position +// getter in either options form runs once. const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -23,6 +25,35 @@ step("positional", (cb) => fs.read(fd, Buffer.alloc(1), 0, 1, big, cb), () => step("buffer and options", (cb) => fs.read(fd, Buffer.alloc(2), { position: big }, cb), () => step("small", (cb) => fs.read(fd, Buffer.alloc(2), 0, 2, 1n, cb), () => step("current", (cb) => fs.read(fd, Buffer.alloc(1), 0, 1, -1n, cb), () => { - fs.closeSync(fd); - fs.rmSync(dir, { recursive: true }); + fractional(); }))))); + +function counted(values) { + const options = { calls: 0 }; + Object.defineProperty(options, "position", { + get() { + return values[options.calls++]; + }, + }); + return options; +} + +function fractional() { + step("fractional", (cb) => fs.read(fd, { buffer: Buffer.alloc(2), length: 0.5, position: 1n }, cb), () => + step("infinite", (cb) => fs.read(fd, Buffer.alloc(2), { length: Infinity, position: 1n }, cb), () => + step("truncated", (cb) => fs.read(fd, { buffer: Buffer.alloc(3), length: 2.7, position: 1n }, cb), () => { + const first = counted([0, 2]); + first.buffer = Buffer.alloc(1); + first.length = 1; + step("getter options", (cb) => fs.read(fd, first, cb), () => { + console.log("getter options calls", first.calls); + const second = counted([2, 0]); + second.length = 1; + step("getter buffer and options", (cb) => fs.read(fd, Buffer.alloc(1), second, cb), () => { + console.log("getter buffer and options calls", second.calls); + fs.closeSync(fd); + fs.rmSync(dir, { recursive: true }); + }); + }); + }))); +} From 1acdda7328bff137cb61fb86203e2a5ec02ee1e6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:33:43 -0500 Subject: [PATCH 063/193] Read and write cached regular files on the loop thread in fs streams On Linux, an fs stream's read or write of a regular file first tries preadv2/pwritev2 with RWF_NOWAIT on the loop thread. When the page cache holds the data, the step completes there and its completion waits for the next loop turn (scr_work_submit_done), as a pool job's completion does, so timers and I/O still run between steps. EAGAIN, a kernel or file system without RWF_NOWAIT, and other files take the pool as before. - A read the cache holds only part of finishes on the pool, so each chunk is the one a single read() returns. - A partial write completes with the bytes written; the stream submits the rest, as after any partial write. - Writes stay on the pool when RLIMIT_FSIZE is finite, so SIGXFSZ cannot reach the loop thread. scr_work_submit_done queues a completed job on a list the loop moves to the done queue at its next sleep step, which is skipped while jobs wait. Corpus 8530 covers a positioned read with a small highWaterMark, a write stream with a start position into an existing file, and an append stream over cached files, against Node. --- .../8530-fs-stream-cached-positions.ts.json | 6 ++ packages/runtime/src/scr_async.c | 89 ++++++++++++++++++- packages/runtime/src/scr_fs_stream.c | 75 +++++++++++++++- packages/runtime/src/scr_lib.h | 4 + .../corpus/8530-fs-stream-cached-positions.ts | 36 ++++++++ 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-fs-stream-cached-positions.ts.json create mode 100644 tests/corpus/8530-fs-stream-cached-positions.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-fs-stream-cached-positions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-fs-stream-cached-positions.ts.json new file mode 100644 index 000000000..9fd2b0d20 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-fs-stream-cached-positions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8530-fs-stream-cached-positions.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 8f540d3d6..872026b97 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3540,15 +3540,25 @@ struct ScrFsRenameOwner { ScrFsRenameOp **tail; size_t pending; ScrFsRenameWake wake; + ScrFsRenameOp *next_turn; + ScrFsRenameOp **next_turn_tail; }; static SCR_TL ScrFsRenameOwner *scr_fs_rename_owner; #define scr_fs_rename_done (scr_fs_rename_owner->done) #define scr_fs_rename_done_tail (scr_fs_rename_owner->tail) #define scr_fs_rename_pending_count (scr_fs_rename_owner->pending) +#define scr_fs_rename_next_turn (scr_fs_rename_owner->next_turn) +#define scr_fs_rename_next_turn_tail (scr_fs_rename_owner->next_turn_tail) #else static ScrFsRenameOp *scr_fs_rename_done = NULL; static ScrFsRenameOp **scr_fs_rename_done_tail = &scr_fs_rename_done; static size_t scr_fs_rename_pending_count = 0; +/* Jobs the main thread completed itself (scr_work_submit_done): they join + * the done queue at the loop's next sleep step (scr_fs_renames_promote), so + * their completions wait one loop turn as a pool job's do. Main thread + * only, so no lock. */ +static ScrFsRenameOp *scr_fs_rename_next_turn = NULL; +static ScrFsRenameOp **scr_fs_rename_next_turn_tail = &scr_fs_rename_next_turn; static ScrFsRenameWake scr_fs_rename_wake_global = SCR_FS_RENAME_WAKE_NONE; #endif static size_t scr_fs_rename_worker_count = 0; @@ -3659,6 +3669,13 @@ static void scr_fs_rename_op_release(ScrFsRenameOp *op) { #ifdef SCR_WORKERS static void scr_fs_rename_context_cleanup(void) { ScrFsRenameOwner *owner = scr_fs_rename_owner; + /* Jobs completed on this thread need no wait. */ + while (owner->next_turn) { + ScrFsRenameOp *op = owner->next_turn; + owner->next_turn = op->next; + owner->pending--; + scr_fs_rename_op_release(op); + } /* Outstanding native work may still borrow payload storage. Wait for its * publication before reclaiming it, without invoking script callbacks * during context teardown. Other script contexts keep using the pool. */ @@ -3682,6 +3699,7 @@ static ScrFsRenameOwner *scr_fs_rename_context(void) { ScrFsRenameOwner *owner = calloc(1, sizeof(*owner)); if (!owner) scr_oom(); owner->tail = &owner->done; + owner->next_turn_tail = &owner->next_turn; owner->wake = (ScrFsRenameWake)SCR_FS_RENAME_WAKE_NONE; if (scr_atexit(scr_fs_rename_context_cleanup) != 0) { free(owner); @@ -3800,9 +3818,33 @@ static void scr_fs_renames_shutdown(void) { scr_fs_rename_op_release(done); done = next; } + while (scr_fs_rename_next_turn != NULL) { + ScrFsRenameOp *next = scr_fs_rename_next_turn->next; + scr_fs_rename_op_release(scr_fs_rename_next_turn); + scr_fs_rename_next_turn = next; + } + scr_fs_rename_next_turn_tail = &scr_fs_rename_next_turn; #endif } +/* At the loop's sleep step: the jobs this thread completed during the turn + * join the done queue, so the next turn delivers them, one per checkpoint + * as pool completions are. Answers whether any joined (the sleep is then + * skipped). */ +static bool scr_fs_renames_promote(void) { +#ifdef SCR_WORKERS + if (!scr_fs_rename_owner) return false; +#endif + if (scr_fs_rename_next_turn == NULL) return false; + scr_fs_rename_lock_enter(); + *scr_fs_rename_done_tail = scr_fs_rename_next_turn; + scr_fs_rename_done_tail = scr_fs_rename_next_turn_tail; + scr_fs_rename_lock_leave(); + scr_fs_rename_next_turn = NULL; + scr_fs_rename_next_turn_tail = &scr_fs_rename_next_turn; + return true; +} + static bool scr_fs_renames_pending(void) { #ifdef SCR_WORKERS if (!scr_fs_rename_owner) return false; @@ -3966,6 +4008,7 @@ static size_t scr_fs_rename_pending_count = 0; static bool scr_fs_renames_pending(void) { return scr_fs_rename_pending_count != 0; } static int scr_fs_renames_pollfd(void) { return -1; } static bool scr_fs_renames_can_wait(void) { return false; } +static bool scr_fs_renames_promote(void) { return false; } static bool scr_fs_renames_dispatch(void) { ScrFsRenameOp *op = scr_fs_rename_done; @@ -4005,6 +4048,7 @@ static bool scr_fs_renames_pending(void) { return false; } static bool scr_fs_renames_dispatch(void) { return false; } static int scr_fs_renames_pollfd(void) { return -1; } static bool scr_fs_renames_can_wait(void) { return false; } +static bool scr_fs_renames_promote(void) { return false; } #endif void scr_fs_rename_async(ScrStr *oldpath, ScrStr *newpath, @@ -4069,6 +4113,44 @@ void scr_work_submit(void *payload, ScrWorkFn work, ScrWorkFn after, #endif } +#if defined(__wasi__) +static void scr_work_already_done(void *payload) { (void)payload; } +#endif + +void scr_work_submit_done(void *payload, ScrWorkFn after, ScrWorkFn destroy) { +#if defined(SCR_LIB) + (void)after; + destroy(payload); + scr_trap("scriptc: native worker jobs are not supported by this target\n"); +#else + ScrFsRenameOp *op = calloc(1, sizeof *op); + if (!op) scr_trap("scriptc: out of memory\n"); + op->generic = true; + op->payload = payload; + op->after = after; + op->destroy = destroy; +#if defined(__wasi__) + op->work = &scr_work_already_done; /* the dispatch runs work, then after */ + *scr_fs_rename_done_tail = op; + scr_fs_rename_done_tail = &op->next; +#else + op->ran = true; +#ifdef SCR_WORKERS + op->owner = scr_fs_rename_context(); +#endif + if (!scr_fs_rename_shutdown_registered) { + if (atexit(scr_fs_renames_shutdown) != 0) { + scr_trap("scriptc: could not register worker cleanup\n"); + } + scr_fs_rename_shutdown_registered = true; + } + *scr_fs_rename_next_turn_tail = op; + scr_fs_rename_next_turn_tail = &op->next; +#endif + scr_fs_rename_pending_count++; +#endif +} + void scr_fs_rename_thunk0(ScrClosure *cb, ScrError *err) { (void)err; ((void (*)(ScrClosure *))cb->fn)(cb); @@ -5238,11 +5320,13 @@ void scr_loop_hosted_io_state(ScrNodeIoState *st) { * end of the turn that queued them and run in the next one, as the loop * pass delivers them; Node's own fs requests take a turn or more too. */ if (st->phase == SCR_NODE_IO_CHECK) scr_loop_deferred_ready = scr_loop_deferred_count; + /* jobs this thread completed during the turn: delivered next turn */ + bool promoted = st->phase == SCR_NODE_IO_CHECK && scr_fs_renames_promote(); st->alive = kids_reffed || kids_failed || events || net || dgram || watch || ffi || stream || renames || crypto || deferred || workers; /* Queued deferred callbacks keep the poll from sleeping (their work is * done; only the delivery waits for the next turn). */ - st->ready = stream || crypto || deferred || kids_failed || + st->ready = stream || crypto || deferred || promoted || kids_failed || (scr_loop_children != NULL && scr_loop_children->ready()); #ifdef SCR_WORKERS if (scr_workers_ready_fn && scr_workers_ready_fn()) st->ready = 1; @@ -5699,6 +5783,9 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { due = now; scr_loop_deferred_ready = scr_loop_deferred_count; } + /* Jobs this thread completed during the turn (scr_work_submit_done) + * join the done queue: the next turn delivers them, no sleep. */ + if (scr_fs_renames_promote()) due = now; /* Child polling can drain a pipe into the IPC queue after its dispatch * station has run. Completed sends and disconnects can also become ready * during that turn. No fd will wake us for this userspace work: return to diff --git a/packages/runtime/src/scr_fs_stream.c b/packages/runtime/src/scr_fs_stream.c index 940068a7b..421438b94 100644 --- a/packages/runtime/src/scr_fs_stream.c +++ b/packages/runtime/src/scr_fs_stream.c @@ -19,6 +19,11 @@ #include #include #include +#if defined(__linux__) && !defined(__ANDROID__) +#include +#include +#include +#endif #define SCR_FSS_MAX_SAFE 9007199254740991.0 @@ -39,6 +44,10 @@ typedef struct ScrFsStream { ScrError *destroy_err; /* owned: the waiting _destroy's error */ void *owner; /* a FileHandle the stream reads or writes, or NULL */ const ScrFsStreamOwnerOps *owner_ops; + /* the descriptor scr_fss_try_inline last checked (0 before the first + * check) and whether its reads and writes may run on the loop thread */ + int inline_fd; + bool inline_ok; } ScrFsStream; static void scr_fss_free(void *p) { @@ -77,6 +86,7 @@ typedef struct ScrFssOp { size_t off, len; double pos; /* < 0: the current offset */ int64_t result; /* answer or -errno */ + size_t got; /* READ: bytes the loop thread already read into buf */ int retries; /* WRITE: zero-byte retries */ ScrError *carry; /* FSYNC/CLOSE: the error to pass on (+1) */ /* The AsyncLocalStorage context the step was started in (+1): its @@ -88,7 +98,13 @@ static void scr_fss_work(void *p) { ScrFssOp *op = p; switch (op->kind) { case SCR_FSS_OPEN: op->result = scr_fs_raw_open(op->path, op->oflags, op->mode); break; - case SCR_FSS_READ: op->result = scr_fs_raw_read(op->fd, op->buf, op->len, op->pos); break; + case SCR_FSS_READ: { + /* the rest of a read the loop thread began (scr_fss_try_inline) */ + int64_t r = scr_fs_raw_read(op->fd, op->buf + op->got, op->len - op->got, + op->pos >= 0 ? op->pos + (double)op->got : -1); + op->result = op->got == 0 ? r : r < 0 ? (int64_t)op->got : (int64_t)op->got + r; + break; + } case SCR_FSS_WRITE: op->result = scr_fs_raw_write(op->fd, op->chunk->data + op->off, op->len, op->pos); break; @@ -127,7 +143,64 @@ static void scr_fss_after_in_context(void *p) { scr_als_dispatch_leave(prev); } +#if defined(__linux__) && !defined(__ANDROID__) && defined(RWF_NOWAIT) +/* Without a file-size limit a regular file's write cannot raise SIGXFSZ on + * the loop thread (scr_fs_write_once's guard is then not needed). */ +static bool scr_fss_size_unlimited(void) { + static int unlimited = -1; + if (unlimited < 0) { + struct rlimit lim; + unlimited = getrlimit(RLIMIT_FSIZE, &lim) == 0 && lim.rlim_cur == RLIM_INFINITY; + } + return unlimited == 1; +} + +/* A read or write of a regular file whose data the page cache holds runs on + * the loop thread: RWF_NOWAIT answers EAGAIN instead of blocking. The + * completion still waits for the next loop turn (scr_work_submit_done), as + * a pool job's does. A read the cache holds only part of finishes on the + * pool, so the chunk is the one a single read() returns; a partial write + * completes with the bytes written, and the rest is submitted again, as + * after any partial write. Answers whether the step completed here. */ +static bool scr_fss_try_inline(ScrFssOp *op) { + if (op->kind != SCR_FSS_READ && op->kind != SCR_FSS_WRITE) return false; + ScrFsStream *f = scr_fss_of(op->s); + if (f->inline_fd != op->fd + 1) { + struct stat st; + f->inline_fd = op->fd + 1; + f->inline_ok = fstat(op->fd, &st) == 0 && S_ISREG(st.st_mode); + } + if (!f->inline_ok || op->len == 0) return false; + if (op->kind == SCR_FSS_WRITE && !scr_fss_size_unlimited()) return false; + off_t at = op->pos >= 0 ? (off_t)op->pos : -1; + ssize_t n; + if (op->kind == SCR_FSS_READ) { + struct iovec iov = {op->buf, op->len}; + n = preadv2(op->fd, &iov, 1, at, RWF_NOWAIT); + } else { + struct iovec iov = {op->chunk->data + op->off, op->len}; + n = pwritev2(op->fd, &iov, 1, at, RWF_NOWAIT); + } + if (n < 0) { + /* a kernel or file system without RWF_NOWAIT: the pool from now on */ + if (errno == EOPNOTSUPP || errno == EINVAL || errno == ENOSYS) f->inline_ok = false; + return false; + } + if (n == 0 && op->kind == SCR_FSS_WRITE) return false; + if (op->kind == SCR_FSS_READ && n > 0 && (size_t)n < op->len) { + op->got = (size_t)n; + return false; + } + op->result = n; + scr_work_submit_done(op, &scr_fss_after_in_context, &scr_fss_op_destroy); + return true; +} +#endif + static void scr_fss_submit(ScrFssOp *op) { +#if defined(__linux__) && !defined(__ANDROID__) && defined(RWF_NOWAIT) + if (scr_fss_try_inline(op)) return; +#endif scr_work_submit(op, &scr_fss_work, &scr_fss_after_in_context, &scr_fss_op_destroy); } diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index bc0184573..daaf9aae0 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -575,6 +575,10 @@ int scr_fs_raw_close(int fd); typedef void (*ScrWorkFn)(void *payload); void scr_work_submit(void *payload, ScrWorkFn work, ScrWorkFn after, ScrWorkFn destroy); +/* A job whose work already ran on the main runtime thread: `after` runs on + * the next loop turn, as a pool job's completion does, and `destroy` + * follows it. */ +void scr_work_submit_done(void *payload, ScrWorkFn after, ScrWorkFn destroy); /* fs.rename: the syscall is submitted to a native worker immediately and * its error-first callback fires on a later event-loop turn through an diff --git a/tests/corpus/8530-fs-stream-cached-positions.ts b/tests/corpus/8530-fs-stream-cached-positions.ts new file mode 100644 index 000000000..1361085d3 --- /dev/null +++ b/tests/corpus/8530-fs-stream-cached-positions.ts @@ -0,0 +1,36 @@ +// fs streams over a cached regular file: chunk sizes of a positioned read +// with a small highWaterMark, a write stream with a start position into an +// existing file, and an append stream, checked against the file contents. +import { createReadStream, createWriteStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dir = mkdtempSync(join(tmpdir(), "scr-fs-cached-")); +const file = join(dir, "data.bin"); +const bytes = Buffer.alloc(100000); +for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) & 0xff; +writeFileSync(file, bytes); + +const sizes: number[] = []; +let sum = 0; +createReadStream(file, { start: 1000, end: 50999, highWaterMark: 4096 }) + .on("data", (c: Buffer) => { + sizes.push(c.length); + for (const b of c) sum = (sum + b) % 65521; + }) + .on("end", () => { + console.log(`read chunks=${sizes.length} first=${sizes[0]} last=${sizes[sizes.length - 1]} sum=${sum}`); + const out = createWriteStream(file, { flags: "r+", start: 10 }); + for (let i = 0; i < 50; i++) out.write(Buffer.alloc(1000, i)); + out.end(() => { + const after = readFileSync(file); + console.log(`positioned len=${after.length} at10=${after[10]} at1009=${after[1009]} at1010=${after[1010]} at50009=${after[50009]} at50010=${after[50010]}`); + const app = createWriteStream(file, { flags: "a" }); + app.write("tail-1;"); + app.end("tail-2", () => { + const final = readFileSync(file); + console.log(`append len=${final.length} end=${final.subarray(final.length - 13).toString()}`); + rmSync(dir, { recursive: true }); + }); + }); + }); From ea0cdd04186a89fde6c547c68efe425762a24323 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:36:29 -0500 Subject: [PATCH 064/193] Match macOS objects and paths in the native install, loop poll and profile tests The nightly macOS lanes failed these tests on Linux-specific expectations: - native-install: installNativeCli resolves the installation directory, and on macOS /tmp is a link to /private/tmp. The fixture root is now resolved too. - counted-loop-emission: two tests counted polls of @scr_exc_flag, the thread's pending word that ELF executables read. Other targets poll the process-wide alert word. The tests now emit for x86_64 Linux, as the file's other ELF tests do. - native-partitions: Mach-O objects define an ltmp label at the start of each section, which the function lists now leave out. - native-profile: profile temperature appears in ELF section names (.text.hot.); Mach-O sections carry no such prefix. The section name check, and the check that recovered block counts change the objects (they move f4 into the hot section), now run on ELF objects only. --- packages/cli/test/native-install.test.ts | 7 +++++-- packages/compiler/test/counted-loop-emission.test.ts | 11 +++++++++-- packages/compiler/test/native-partitions.test.ts | 12 ++++++++++-- packages/compiler/test/native-profile.test.ts | 11 +++++++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/cli/test/native-install.test.ts b/packages/cli/test/native-install.test.ts index 7a6d3ef16..369bc392b 100644 --- a/packages/cli/test/native-install.test.ts +++ b/packages/cli/test/native-install.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, + realpathSync, renameSync, rmSync, statSync, @@ -22,8 +23,10 @@ afterEach(() => { }); function fixture() { - const root = mkdtempSync( - join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-install-"), + // installNativeCli resolves the installation directory, and on macOS /tmp + // is a link to /private/tmp, so the fixture's paths start resolved. + const root = realpathSync( + mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-install-")), ); roots.push(root); const directory = join(root, "installation"); diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index 41f96e006..30a139a81 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -85,7 +85,11 @@ test("worker programs poll counted loops over typed arrays between blocks of ite const outPath = join(dir, `${workers ? "worker" : "plain"}.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 emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + // ELF executables poll the thread's pending word (@scr_exc_flag); + // other targets read the process-wide alert word. + return emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")), { + targetTriple: "x86_64-unknown-linux-gnu", + }); }; // Every emitted version of a function (the number and int32 entries). const bodies = (ll: string, name: string): string => @@ -254,7 +258,10 @@ test("worker programs poll no loop whose proven ranges bound its runs inside ano const outPath = join(dir, "bounded.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")); - const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + // The polls counted below read the thread's pending word of ELF executables. + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")), { + targetTriple: "x86_64-unknown-linux-gnu", + }); const body = (name: string): string => [ ...ll.matchAll( diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index ea906eea7..52c6b42f1 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -8,6 +8,9 @@ import { afterEach, describe, expect, test } from "vitest"; import { nativeCodegenTarget, nativeHelperForTarget } from "../src/backend/targets.js"; const execFileAsync = promisify(execFile); +// Mach-O objects define a local label (ltmp0, ltmp1, ...) at the start of +// each section; it names no function. +const machoSectionLabel = /^ltmp\d+$/; const target = nativeCodegenTarget(); const helperSpec = target === null ? null : nativeHelperForTarget(target); const helper = (() => { @@ -264,7 +267,7 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { return stdout .split("\n") .map((line) => /^[0-9a-fA-F]+ [Tt] _?(\w+)$/.exec(line.trim())?.[1]) - .filter((name): name is string => name !== undefined); + .filter((name): name is string => name !== undefined && !machoSectionLabel.test(name)); }; const whole = await defined( (await emit(dir, program(), { outputs: 1, optLevel: "2", name: "whole" }))[0]!, @@ -390,7 +393,12 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { expect(stdout).toMatch(/ t _?heavy$/m); for (const part of parts.slice(1)) { const listed = await execFileAsync("nm", ["--defined-only", part]); - expect(listed.stdout.trim()).toBe(""); + const symbols = listed.stdout + .split("\n") + .filter( + (line) => line.trim() !== "" && !machoSectionLabel.test(line.trim().split(" ").at(-1)!), + ); + expect(symbols).toEqual([]); } // The first output is the object that a single output produces. const first = await readFile(parts[0]!); diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts index e860d4010..7ef9034cb 100644 --- a/packages/compiler/test/native-profile.test.ts +++ b/packages/compiler/test/native-profile.test.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { nativeCodegenTarget, nativeHelperForTarget } from "../src/backend/targets.js"; const execFileAsync = promisify(execFile); +const isElf = (object: Buffer) => object.subarray(0, 4).equals(Buffer.from("\x7fELF", "latin1")); const target = nativeCodegenTarget(); const helperSpec = target === null ? null : nativeHelperForTarget(target); const helper = (() => { @@ -327,7 +328,9 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { use: merged, }); const bytes = Buffer.concat(await Promise.all(objects.map((object) => readFile(object)))); - expect(bytes.includes(".text.hot.main")).toBe(true); + // ELF objects name each function's section by its profile temperature; + // Mach-O sections carry no such prefix. + if (isElf(bytes)) expect(bytes.includes(".text.hot.main")).toBe(true); const optimized = await link(dir, objects, "laidout"); expect(await run(optimized, 1)).toBe(inlinedExit(1)); expect(await run(optimized, 3)).toBe(inlinedExit(3)); @@ -497,8 +500,12 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { recovered: 1, }); expect(await run(await link(dir, recovered.outputs, "recovered"), 2)).toBe(expectedExit(2)); + // The recovered counts move f4 into the hot section. ELF objects name + // it (.text.hot.); Mach-O sections carry no such prefix, so there the + // two builds are the same. const bytes = (paths: string[]) => Promise.all(paths.map((path) => readFile(path))); - expect(await bytes(recovered.outputs)).not.toEqual(await bytes(stale.outputs)); + const recoveredBytes = await bytes(recovered.outputs); + if (isElf(recoveredBytes[0]!)) expect(recoveredBytes).not.toEqual(await bytes(stale.outputs)); // The block counts key the partition cache. const before = await cacheEntries(cache); From 593673f87a041449b0fdd04a18beb0f21278646d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:38:22 -0500 Subject: [PATCH 065/193] Provide the libnode pack to the shape overlay npm lanes The scheduled shape overlay lanes for npm and npm-static failed: on Linux x64 CI the npm suites' Node island cases require SCRIPTC_LIBNODE (tests/harness/libnode.ts), and this job did not run .github/actions/libnode. The job now runs it for those two suites, as the other Linux x64 and macOS test lanes do. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23936e46a..99c9702fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -464,6 +464,10 @@ jobs: name: native-linux-x64-gnu-shape-overlay path: ${{ runner.temp }} - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" + # The npm suites' Node island cases load Node from a libnode pack, and + # fail rather than skip in this lane when none is configured. + - uses: ./.github/actions/libnode + if: matrix.suite == 'npm' || matrix.suite == 'npm-static' - name: Run the suite with the overlay on run: | case "${{ matrix.suite }}" in From bbec52d459c3b5f2eecae756456e1cce1e5f9200 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:54:29 -0500 Subject: [PATCH 066/193] Give class methods and function values jsval entries for untyped calls --- packages/cli/test/native-link-info.test.ts | 4 +- packages/compiler/src/backend/llvm/dyn.ts | 146 ++++++ packages/compiler/src/backend/llvm/emitter.ts | 38 +- .../compiler/src/backend/llvm/expr-dynamic.ts | 206 +++++++- packages/compiler/src/backend/llvm/js-ic.ts | 2 +- .../compiler/src/backend/llvm/unwind-abi.ts | 9 +- packages/compiler/src/backend/runtime-abi.ts | 4 +- .../src/backend/runtime-pack-targets.test.ts | 2 +- .../compiler/src/backend/runtime-pack.test.ts | 2 +- .../src/ir/function-references.test.ts | 6 + .../compiler/src/ir/function-references.ts | 10 +- packages/compiler/src/ir/ir.ts | 12 +- packages/compiler/src/ir/js-ic.test.ts | 12 +- packages/compiler/src/ir/js-ic.ts | 66 ++- packages/compiler/src/ir/jsval.test.ts | 98 +++- packages/compiler/src/ir/jsval.ts | 445 +++++++++++++++--- packages/compiler/src/ir/runtime-features.ts | 7 + .../compiler/src/ir/self-captures.test.ts | 23 + packages/compiler/src/ir/self-captures.ts | 9 +- packages/compiler/src/ir/validate.ts | 4 +- .../test/native-codegen-integration.test.ts | 4 +- .../8600-dynamic-class-method-numbers.js.json | 6 + ...601-dynamic-function-value-numbers.js.json | 6 + .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_console.c | 2 +- packages/runtime/src/scr_core.h | 2 +- packages/runtime/src/scr_dyn.h | 11 +- packages/runtime/src/scr_json.c | 11 + .../8600-dynamic-class-method-numbers.js | 66 +++ .../8601-dynamic-function-value-numbers.js | 92 ++++ .../8600-dynamic-class-method-numbers.js.json | 1 + ...601-dynamic-function-value-numbers.js.json | 1 + 33 files changed, 1181 insertions(+), 130 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json create mode 100644 tests/corpus/8600-dynamic-class-method-numbers.js create mode 100644 tests/corpus/8601-dynamic-function-value-numbers.js create mode 100644 tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json create mode 100644 tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index b838b3155..162165f28 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" }, + runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v47"); + expect(error?.stderr).toContain("scr_runtime_abi_v48"); }); }); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 58bad60af..e5a1ead61 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -57,6 +57,7 @@ import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js"; import { mangleRecordNew, mangleRecordStruct } from "../mangle.js"; import { BlockBuilder } from "./blocks.js"; import { llvmCommentText } from "./common.js"; +import { JS_BITS } from "./jsval-encoding.js"; import { arrNewCall, elemAccess, @@ -219,6 +220,21 @@ export interface DynHost extends WalkerHost { classValueSubtypes(className: string): readonly string[]; /** emitter.ts's pendingTestLines: the inline pending-exception test. */ pendingTestLines(dest: string): string[]; + /** The closures with a jsval body (IrFunction.jsEntry) on targets with + * jsval words, which the box builders give a jsval entry. */ + jsEntryClosures?(): readonly JsEntryClosure[]; +} + +/** A closure with a jsval body (IrFunction.jsEntry), for the box builders. */ +export interface JsEntryClosure { + /** The closure's entry symbol (ScrClosure.fn) and signature. */ + closure: string; + params: readonly IrType[]; + ret: IrType; + /** The jsval body's entry symbol and signature. */ + body: string; + bodyParams: readonly IrType[]; + bodyRet: IrType; } const FN_ATTRS = "#0"; @@ -247,6 +263,7 @@ export class LlDyn { private readonly toDynFns = new Map(); private readonly dynFuncThunks = new Map(); private readonly dynFuncBoxes = new Map(); + private readonly jsEntryThunks = new Map(); private readonly dynFuncAdapters = new Map(); private readonly messageListeners = new Map(); private readonly messageReaders = new Map(); @@ -290,6 +307,8 @@ export class LlDyn { return this.dynFuncThunkHelper(args[0] as IrType & { kind: "func" }); case "dyn.dynFuncBoxHelper": return this.dynFuncBoxHelper(args[0] as IrType & { kind: "func" }); + case "dyn.jsEntryThunk": + return this.jsEntryThunk(args[0] as string, args[1] as IrType[], args[2] as IrType); case "dyn.dynFuncAdapterHelper": return this.dynFuncAdapterHelper(args[0] as IrType & { kind: "func" }); case "dyn.dynToStrHelper": @@ -5534,6 +5553,101 @@ export class LlDyn { return name; } + /** `sc_jt_(ptr c, ptr args, size argc) -> ptr`, a function value's + * jsval entry (ScrDynJsEntry) into a closure's jsval body `target` + * (IrFunction.jsEntry, 64-bit targets): passes each argument word to the + * body, a missing one as undefined and a dyn parameter's boxed, and + * returns the body's result as an owned word. Like the closure's own + * entry, the body consumes its arguments. With an exception pending the + * result is undefined. */ + jsEntryThunk(target: string, params: readonly IrType[], ret: IrType): string { + const existing = this.jsEntryThunks.get(target); + if (existing) return existing; + const name = `sc_jt_${this.jsEntryThunks.size}`; + this.jsEntryThunks.set(target, name); + this.host.journalAllocated?.(name, "dyn.jsEntryThunk", [target, params, ret]); + const host = this.host; + const B = new BlockBuilder(); + const undefinedWord = `inttoptr (i64 ${JS_BITS.undefined} to ptr)`; + const args = params.map((p, i) => { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + B.line(`store ptr ${undefinedWord}, ptr ${slot}`); + const has = B.tmp(); + B.line(`${has} = icmp ult ${host.sizeType} ${i}, %argc`); + const lHas = B.newLabel("jt.h"); + const lj = B.newLabel("jt.j"); + B.condBr(has, lHas, lj); + B.startBlock(lHas); + const ap = B.tmp(); + const av = B.tmp(); + B.line(`${ap} = getelementptr inbounds ptr, ptr %args, ${host.sizeType} ${i}`); + B.line(`${av} = load ptr, ptr ${ap}`); + B.line(`store ptr ${av}, ptr ${slot}`); + B.br(lj); + B.startBlock(lj); + const word = B.tmp(); + B.line(`${word} = load ptr, ptr ${slot}`); + const owned = B.tmp(); + if (p.kind === "jsval") { + host.declare(`declare ptr @scr_js_retain(ptr)`); + B.line(`${owned} = call ptr @scr_js_retain(ptr ${word})`); + } else if (p.kind === "dyn") { + host.declare(`declare ptr @scr_js_to_dyn_borrow(ptr)`); + B.line(`${owned} = call ptr @scr_js_to_dyn_borrow(ptr ${word})`); + } else { + throw new InternalCompilerError(`llvm emitter bug: jsval entry parameter ${p.kind}`); + } + return `ptr ${owned}`; + }); + const callArgs = [`ptr %c`, ...args].join(", "); + if (ret.kind === "void") { + B.line(`call void @${target}(${callArgs})`); + this.pendingBail(B, "jtr", () => {}, `ptr ${undefinedWord}`); + B.terminate(`ret ptr ${undefinedWord}`); + } else { + const r = B.tmp(); + B.line(`${r} = call ${this.valTy(ret)} @${target}(${callArgs})`); + this.pendingBail(B, "jtr", () => {}, `ptr ${undefinedWord}`); + if (ret.kind === "jsval") { + B.terminate(`ret ptr ${r}`); + } else if (ret.kind === "f64") { + const raw = B.tmp(); + const nan = B.tmp(); + const bits = B.tmp(); + const word = B.tmp(); + B.line(`${raw} = bitcast double ${r} to i64`); + B.line(`${nan} = fcmp uno double ${r}, ${r}`); + B.line(`${bits} = select i1 ${nan}, i64 ${JS_BITS.canonicalNan}, i64 ${raw}`); + B.line(`${word} = inttoptr i64 ${bits} to ptr`); + B.terminate(`ret ptr ${word}`); + } else if (ret.kind === "bool") { + const bits = B.tmp(); + const word = B.tmp(); + B.line(`${bits} = select i1 ${r}, i64 ${JS_BITS.true}, i64 ${JS_BITS.false}`); + B.line(`${word} = inttoptr i64 ${bits} to ptr`); + B.terminate(`ret ptr ${word}`); + } else { + // dyn, or a typed result boxed as the closure's own entry would. + const d = ret.kind === "dyn" ? r : this.toDynExpr(B, ret, r); + if (ret.kind !== "dyn" && isRefCounted(ret)) + B.line(`call void ${releaseSym(host, ret)}(ptr ${r})`); + host.declare(`declare ptr @scr_js_from_dyn(ptr)`); + const word = B.tmp(); + B.line(`${word} = call ptr @scr_js_from_dyn(ptr ${d})`); + B.terminate(`ret ptr ${word}`); + } + } + this.defs.push( + `define internal ptr @${name}(ptr %c, ptr %args, ${host.sizeType} %argc) ${FN_ATTRS} { ; jsval entry into ${llvmCommentText(target)}`, + B.render(), + `}`, + ``, + ); + this.host.journalBuilt?.(); + return name; + } + /** The box builder dynFrom emits for one closure signature. */ dynFuncBoxHelper(t: IrType & { kind: "func" }): string { const key = typeKey(t); @@ -5547,11 +5661,43 @@ export class LlDyn { host.declare(`declare ptr @scr_closure_retain_v(ptr)`); host.declare(`declare ptr @scr_dyn_new_func(ptr, ptr, i32, ptr, ptr)`); const sigLit = host.cstr(key); + // A closure of this signature with a jsval body gets the function + // value's jsval entry, however the value is boxed: the closure's + // entry pointer names it. + const entries = + t.rest === true + ? [] + : (host.jsEntryClosures?.() ?? []).filter( + (c) => + c.params.length === t.params.length && + c.params.every((p, i) => typeKey(p) === typeKey(t.params[i]!)) && + typeKey(c.ret) === typeKey(t.ret), + ); + const register: string[] = []; + if (entries.length > 0) { + host.declare(`declare void @scr_dyn_func_set_js(ptr, ptr, ptr)`); + register.push( + ` %fnp = getelementptr inbounds %ScrClosure, ptr %c, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ); + entries.forEach((c, i) => { + const jsEntry = this.jsEntryThunk(c.body, c.bodyParams, c.bodyRet); + register.push( + ` %is${i} = icmp eq ptr %fn, @${c.closure}`, + ` br i1 %is${i}, label %set${i}, label %next${i}`, + `set${i}:`, + ` call void @scr_dyn_func_set_js(ptr %r, ptr @${thunk}, ptr @${jsEntry})`, + ` ret ptr %r`, + `next${i}:`, + ); + }); + } this.defs.push( `define internal ptr @${name}(ptr %v, ptr %fname) ${FN_ATTRS} { ; box ${key} into dyn`, `entry:`, ` %c = call ptr @scr_closure_retain_v(ptr %v)`, ` %r = call ptr @scr_dyn_new_func(ptr %c, ptr @${thunk}, i32 ${t.params.length - (t.restAbi === "typed" ? 1 : 0)}, ptr ${sigLit}, ptr %fname)`, + ...register, ` ret ptr %r`, `}`, ``, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e6a72807c..a6848fa05 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5,7 +5,7 @@ import type { CompilationTiming } from "../../timing.js"; import { relieveHeapPressure } from "../../heap-pressure.js"; import { analyzeFrameEscapes, type FrameEscapes } from "./frame-escapes.js"; import { wasmBoundsChecks } from "./wasm-modules.js"; -import { jsTruthy } from "./jsval.js"; +import { jsImmediates, jsTruthy } from "./jsval.js"; import { JsIcSites } from "../../ir/js-ic.js"; import { type ArraySearchKind } from "./array-search.js"; import { emitStringInputs } from "./string-lifetimes.js"; @@ -374,7 +374,7 @@ import { type ArenaPlan, } from "./arenas.js"; import { children, type Node } from "./region-ir.js"; -import { LlDyn, recordDynKeepsIdentity, type DynHost } from "./dyn.js"; +import { LlDyn, recordDynKeepsIdentity, type DynHost, type JsEntryClosure } from "./dyn.js"; import { VirtualBorrows } from "./virtual-borrows.js"; import { planRecordViews, recordViewFieldPtr } from "./record-views.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -1485,6 +1485,10 @@ export class LlEmitter { private readonly rcHelpersNoinline: boolean; readonly fnByName = new Map(); + /** Whether some closure has a jsval body (IrFunction.jsEntry): the + * dynamic tier's dynCall and dynInvoke sites then call a function value's + * jsval entry when it has one. */ + jsEntries = false; /** Empty literals that construct with inline slots (appended-array-literals.ts). */ appendedArrayLiterals: ReadonlySet = new Set(); /** Emitted LLVM text of every IR function body, by function name: the @@ -2180,7 +2184,10 @@ export class LlEmitter { this.ffiCallbackAdapters = allocateFfiCallbackAdapters(mod.ffiImports ?? []); this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []); this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []); - for (const fn of mod.functions) this.fnByName.set(fn.name, fn); + for (const fn of mod.functions) { + this.fnByName.set(fn.name, fn); + if (fn.jsEntry !== undefined) this.jsEntries = true; + } this.appendedArrayLiterals = findAppendedArrayLiterals(mod, this.nodeIndex); for (const entry of mod.ffiImports ?? []) { this.ffiByName.set(entry.name, entry); @@ -2396,6 +2403,7 @@ export class LlEmitter { ? undefined : (table, key, value) => this.journal?.entry(table, key, value), compactArrayElem: (elem) => this.compactArrayElem(elem), + jsEntryClosures: () => this.jsEntryClosures(), compactRegion: () => this.compactRegion, sizeType: this.sizeType, cycleBufOffset: this.cycleBufOffset, @@ -5568,6 +5576,30 @@ export class LlEmitter { * the fiber eagerly to its first suspension and returns the promise); * generator bodies via theirs (which only ALLOCATES the suspended * fiber and returns the generator object). */ + private jsEntryClosureList: readonly JsEntryClosure[] | null = null; + + /** The closures with a jsval body (IrFunction.jsEntry), on targets with + * jsval words. */ + jsEntryClosures(): readonly JsEntryClosure[] { + if (this.jsEntryClosureList !== null) return this.jsEntryClosureList; + const list: JsEntryClosure[] = []; + if (this.jsEntries && jsImmediates(this)) + for (const fn of this.fnByName.values()) { + const body = fn.jsEntry === undefined ? undefined : this.fnByName.get(fn.jsEntry); + if (body === undefined) continue; + list.push({ + closure: this.callTarget(fn.name), + params: fn.params.map((p) => p.type), + ret: fn.returnType, + body: this.callTarget(body.name), + bodyParams: body.params.map((p) => p.type), + bodyRet: body.returnType, + }); + } + this.jsEntryClosureList = list; + return list; + } + callTarget(fnName: string): string { const fn = this.fnByName.get(fnName); if (fn?.generator !== undefined) return mangleGenSpawn(fnName); diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 7f4b7e48a..4272d3a6e 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -27,11 +27,13 @@ import { } from "../../ir/ir.js"; import { BYTES_ELEM_NUM, closureIdentityEqual } from "./common.js"; import { DYN_KIND } from "./dyn.js"; -import { elemAccess, vAdapters } from "./shapes.js"; +import { elemAccess, releaseSym, vAdapters } from "./shapes.js"; +import { JS_BITS } from "./jsval-encoding.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; import { emitUnionWiden } from "./expr-records.js"; import { emitJsIcLiteralMember, emitJsIcMethod, jsIcMethods } from "./js-ic.js"; +import { jsImmediates } from "./jsval.js"; import { jsIcLiteralKey } from "../../ir/js-ic.js"; import { DYN_BUFFER_METHODS } from "../../ir/runtime-features.js"; @@ -189,6 +191,30 @@ export function emitDynamicExpr( return out; } const args = e.args.map((a) => emitBorrowedInput(host, a)); + host.declare(`declare ptr @scr_dyn_call(ptr, ptr, ${host.sizeType}, ptr)`); + const fast = jsEntrySites(host); + if (fast || args.some((a) => a.type.kind === "jsval")) { + const name = calleeName(); + const t = emitJsvalArgumentsCall( + host, + callee.name, + receiver?.name ?? "null", + args, + fast, + (argsPtr) => { + const r = B.tmp(); + B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`); + B.line( + `${r} = call ptr @scr_dyn_call(ptr ${callee.name}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`, + ); + B.line("call void @scr_dyn_this_pop()"); + return r; + }, + ); + const out = host.own({ name: t, type: e.type }); + host.emitPendingCheck(); + return out; + } let argsPtr = "null"; if (args.length > 0) { const arr = B.slot(); @@ -202,7 +228,6 @@ export function emitDynamicExpr( }); argsPtr = arr; } - host.declare(`declare ptr @scr_dyn_call(ptr, ptr, ${host.sizeType}, ptr)`); const t = B.tmp(); const name = calleeName(); B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`); @@ -246,6 +271,39 @@ export function emitDynamicExpr( ); } } + // Buffer.prototype method names take their scr_bytes_dyn.c entry, which + // runs them on Buffer receivers and dispatches any other receiver. + // Each call passes its method name's id slot, so only the first call + // looks the name up. + const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id"; + const discardedPush = + discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id"; + const fast = !discardedPush && invoke === "scr_dyn_invoke_prepared_id" && jsEntrySites(host); + if (fast || args.some((a) => a.type.kind === "jsval")) { + const name = discardedPush ? host.cstr(e.calleeName) : dynInvokeCalleeName(host, e); + if (discardedPush) + host.declare( + `declare void @scr_dyn_invoke_discard_id(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`, + ); + else host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`); + const t = emitJsvalArgumentsCall(host, prepared, recv.name, args, fast, (argsPtr) => { + const site = `ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name}`; + if (discardedPush) { + B.line(`call void @scr_dyn_invoke_discard_id(${site})`); + return "null"; + } + const r = B.tmp(); + B.line(`${r} = call ptr @${invoke}(${site})`); + return r; + }); + if (discardedPush) { + host.emitPendingCheck(); + return { name: "", type: VOID }; + } + const out = host.own({ name: t, type: e.type }); + host.emitPendingCheck(); + return out; + } let argsPtr = "null"; if (args.length > 0) { const arr = B.slot(); @@ -259,11 +317,6 @@ export function emitDynamicExpr( }); argsPtr = arr; } - // Buffer.prototype method names take their scr_bytes_dyn.c entry, which - // runs them on Buffer receivers and dispatches any other receiver. - // Each call passes its method name's id slot, so only the first call - // looks the name up. - const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id"; // A statement-position push drops the new length, so its entry // need not box it. if (discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id") { @@ -277,12 +330,7 @@ export function emitDynamicExpr( return { name: "", type: VOID }; } host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`); - let name = host.cstr(e.calleeName); - if (e.calleeNameValue !== undefined) { - const value = emitBorrowedInput(host, e.calleeNameValue); - name = B.tmp(); - B.line(`${name} = getelementptr inbounds %ScrStr, ptr ${value.name}, i32 1`); - } + const name = dynInvokeCalleeName(host, e); const t = B.tmp(); B.line( `${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`, @@ -1389,3 +1437,135 @@ function messageListenerArg(e: ExprOf<"dynInvoke">): (IrType & { kind: "func" }) if (listener?.kind !== "dynFrom" || listener.value.type.kind !== "func") return null; return listener.value.type; } + +/** Whether a dynCall or dynInvoke site of the current function tests its + * callee for a jsval entry (emitJsvalArgumentsCall): dynamic-tier code of + * a module with closure jsval bodies, on targets with jsval words. */ +function jsEntrySites(host: LlvmEmitterContext): boolean { + return ( + host.jsEntries && + jsImmediates(host) && + host.fnByName.get(host.currentFunctionName)?.tier === "dynamic" + ); +} + +/** A call of the function value `callee` (null for a site without one) + * with `args`, which may include jsval words (ir/jsval.ts). With `fast`, + * a callee boxed from a closure with a jsval body (ScrDyn.v.fn.js, +72) + * takes the arguments as words, a dyn argument under the cell tag, with + * `receiver` bound as `this` around the call. Every other call goes + * through `slow`, which takes an array of dyn arguments: the words boxed + * for it and released after it. Returns the owned dyn result (null on + * the slow path with an exception pending). */ +function emitJsvalArgumentsCall( + host: LlvmEmitterContext, + callee: string | null, + receiver: string, + args: readonly LlValue[], + fast: boolean, + slow: (argsPtr: string) => string, +): string { + const B = host.B; + const n = args.length; + const result = B.slot(); + B.entryAllocas.push(`${result} = alloca ptr`); + const lSlow = B.newLabel("jse.slow"); + const lJoin = B.newLabel("jse.join"); + if (fast && callee !== null) { + let words = "null"; + if (n > 0) { + words = B.slot(); + B.entryAllocas.push(`${words} = alloca [${n} x ptr]`); + args.forEach((a, i) => { + let word = a.name; + if (a.type.kind !== "jsval") { + const bits = B.tmp(); + const tagged = B.tmp(); + word = B.tmp(); + B.line(`${bits} = ptrtoint ptr ${a.name} to i64`); + B.line(`${tagged} = or i64 ${bits}, ${JS_BITS.cellTag}`); + B.line(`${word} = inttoptr i64 ${tagged} to ptr`); + } + const p = B.tmp(); + B.line(`${p} = getelementptr inbounds [${n} x ptr], ptr ${words}, i64 0, i64 ${i}`); + B.line(`store ptr ${word}, ptr ${p}`); + }); + } + const lKind = B.newLabel("jse.kind"); + const lFn = B.newLabel("jse.fn"); + const lEntry = B.newLabel("jse.entry"); + const present = B.tmp(); + B.line(`${present} = icmp ne ptr ${callee}, null`); + B.condBr(present, lKind, lSlow); + B.startBlock(lKind); + const kindPtr = B.tmp(); + const kind = B.tmp(); + const isFn = B.tmp(); + B.line(`${kindPtr} = getelementptr inbounds i8, ptr ${callee}, i64 8`); + B.line(`${kind} = load i32, ptr ${kindPtr}`); + B.line(`${isFn} = icmp eq i32 ${kind}, ${DYN_KIND.FUNC}`); + B.condBr(isFn, lFn, lSlow); + B.startBlock(lFn); + const entryPtr = B.tmp(); + const entry = B.tmp(); + const hasEntry = B.tmp(); + B.line(`${entryPtr} = getelementptr inbounds i8, ptr ${callee}, i64 72 ; ->v.fn.js`); + B.line(`${entry} = load ptr, ptr ${entryPtr}`); + B.line(`${hasEntry} = icmp ne ptr ${entry}, null`); + B.condBr(hasEntry, lEntry, lSlow); + B.startBlock(lEntry); + const cloPtr = B.tmp(); + const clo = B.tmp(); + const word = B.tmp(); + const value = B.tmp(); + host.declare("declare void @scr_dyn_this_push_dyn(ptr)"); + host.declare("declare void @scr_dyn_this_pop()"); + host.declare("declare ptr @scr_js_to_dyn(ptr)"); + B.line(`${cloPtr} = getelementptr inbounds i8, ptr ${callee}, i64 16 ; ->v.fn.clo`); + B.line(`${clo} = load ptr, ptr ${cloPtr}`); + B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver})`); + B.line(`${word} = call ptr ${entry}(ptr ${clo}, ptr ${words}, ${host.sizeType} ${n})`); + B.line("call void @scr_dyn_this_pop()"); + B.line(`${value} = call ptr @scr_js_to_dyn(ptr ${word})`); + B.line(`store ptr ${value}, ptr ${result}`); + B.br(lJoin); + } else { + B.br(lSlow); + } + B.startBlock(lSlow); + let cells = "null"; + const boxed: string[] = []; + if (n > 0) { + cells = B.slot(); + B.entryAllocas.push(`${cells} = alloca [${n} x ptr]`); + args.forEach((a, i) => { + let cell = a.name; + if (a.type.kind === "jsval") { + host.declare("declare ptr @scr_js_to_dyn_borrow(ptr)"); + cell = B.tmp(); + B.line(`${cell} = call ptr @scr_js_to_dyn_borrow(ptr ${a.name})`); + boxed.push(cell); + } + const p = B.tmp(); + B.line(`${p} = getelementptr inbounds [${n} x ptr], ptr ${cells}, i64 0, i64 ${i}`); + B.line(`store ptr ${cell}, ptr ${p}`); + }); + } + const r = slow(cells); + for (const cell of boxed) B.line(`call void ${releaseSym(host.shapeHost, DYN)}(ptr ${cell})`); + B.line(`store ptr ${r}, ptr ${result}`); + B.br(lJoin); + B.startBlock(lJoin); + const out = B.tmp(); + B.line(`${out} = load ptr, ptr ${result}`); + return out; +} + +/** A dynInvoke's callee spelling for the runtime's error texts. */ +function dynInvokeCalleeName(host: LlvmEmitterContext, e: ExprOf<"dynInvoke">): string { + if (e.calleeNameValue === undefined) return host.cstr(e.calleeName); + const value = emitBorrowedInput(host, e.calleeNameValue); + const name = host.B.tmp(); + host.B.line(`${name} = getelementptr inbounds %ScrStr, ptr ${value.name}, i32 1`); + return name; +} diff --git a/packages/compiler/src/backend/llvm/js-ic.ts b/packages/compiler/src/backend/llvm/js-ic.ts index c9786c9f2..8b7ed10f9 100644 --- a/packages/compiler/src/backend/llvm/js-ic.ts +++ b/packages/compiler/src/backend/llvm/js-ic.ts @@ -57,7 +57,7 @@ export function jsIcMethods(host: Host): boolean { return ( jsImmediates(host) && host.currentTier === "dynamic" && - jsIcMethodSites(host.currentFunctionName) + jsIcMethodSites(host.currentFunctionName, host.loopDepth > 0) ); } diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index 1e067cb5d..91132cbe2 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -72,9 +72,9 @@ function recursiveFunctions(edges: ReadonlyMap): Set< * `call` from emitted code that can lower it as an `invoke`: it is not * async or a generator, not a constructor, not a method a virtual call can * reach or one a vtable holds (an overridden method), not a closure - * target, not the entry or a library export, not named by any node other - * than a `call`'s callee, and not called from an async or generator body - * (coroutine bodies keep plain calls). + * target or a closure's jsval body, not the entry or a library export, + * not named by any node other than a `call`'s callee, and not called from + * an async or generator body (coroutine bodies keep plain calls). * * A native unwind costs about 0.05 µs a raise and 10 to 25 ns a frame on * x86-64 (scr_unwind.c's cached plans) and about 2 µs a raise and 0.1 µs a @@ -103,6 +103,9 @@ export function unwindAbiFunctions( blocked.add("%error.name.read"); blocked.add("%error.message.read"); for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName); + // A closure's jsval body is called through the function value's jsval + // entry (IrFunction.jsEntry), which runtime calls reach. + for (const fn of mod.functions) if (fn.jsEntry !== undefined) blocked.add(fn.jsEntry); // Methods a virtual call can reach, and the overridden methods of class // hierarchies (whose vtables list them), are called through pointers; the // others are called directly, like functions. A virtual call reaches its listed diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 985ba6203..63cab76f2 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 47 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v47" as const; +export const RUNTIME_ABI_VERSION = 48 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v48" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index fcbe3f08e..e06a1dd27 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" }, + runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 457f96749..96707ceee 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -135,7 +135,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" }, + runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 68e6e379e..b5f2cadae 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -134,6 +134,7 @@ const TYPE_FIELDS: Classified = { const FUNCTION_FIELDS: Record>, Role> = { name: "other", sourceName: "other", + jsEntry: "reference", asyncCacheGlobal: "other", asyncCycleCacheGlobal: "other", "params[].name": "other", @@ -292,6 +293,11 @@ const REFERENCE_FIXTURES: Record IrModule> = { }), ]), entry: () => ({ ...module(), entry: TARGET }), + "functions[].jsEntry": () => { + const mod = module(); + mod.functions.push({ ...entry(), name: "%m0.closure", captures: [], jsEntry: TARGET }); + return mod; + }, "classes[].prototypeDataHelper": () => ({ ...module(), classes: [{ name: "%m0.Cell", fields: [], prototypeDataHelper: TARGET, loc }], diff --git a/packages/compiler/src/ir/function-references.ts b/packages/compiler/src/ir/function-references.ts index 61e6e2797..b4e6b06dc 100644 --- a/packages/compiler/src/ir/function-references.ts +++ b/packages/compiler/src/ir/function-references.ts @@ -14,13 +14,15 @@ export function exprFunctionReference(expr: IrExpr): string | undefined { /** The expression kinds exprFunctionReference answers for. */ export const FUNCTION_REFERENCE_KINDS = ["closure", "orDefault"] as const; -/** Functions the module's metadata names: the entry, library exports and - * the class prototype helpers the runtime calls. Class methods and - * constructors follow the `%.` convention instead of being - * named here; callers that need them derive them from `mod.classes`. */ +/** Functions the module's metadata names: the entry, library exports, + * closures' jsval entries and the class prototype helpers the runtime + * calls. Class methods and constructors follow the `%.` + * convention instead of being named here; callers that need them derive + * them from `mod.classes`. */ export function moduleFunctionReferences(mod: IrModule): string[] { const names = [mod.entry]; for (const entry of mod.lib?.exports ?? []) names.push(entry.fnName); + for (const fn of mod.functions) if (fn.jsEntry !== undefined) names.push(fn.jsEntry); for (const cls of mod.classes ?? []) { if (cls.prototypeDataHelper !== undefined) names.push(cls.prototypeDataHelper); if (cls.instancePrototypeHelper !== undefined) names.push(cls.instancePrototypeHelper); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 63814a60c..052e950eb 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2034,6 +2034,10 @@ export interface IrFunction { sourceName?: string; /** Ordinary authored function declarations/expressions own a prototype. */ ownsPrototype?: true; + /** A closure body's jsval entry (ir/jsval.ts): a function with the same + * captures that takes jsval parameters. The backend gives each function + * value boxed from this closure a jsval call entry into it. */ + jsEntry?: string; /** The function's execution tier (ir/tiers.ts). Absent means static: the * function may use dynamic values only through boundary operations * (dynFrom, dynCheck, kind tests) and moves, which the validator enforces @@ -3533,7 +3537,10 @@ export type IrExpr = * message exactly). A function callee calls through the boxed thunk: * per-arg validation against the boxed signature (mismatches throw the * path-annotated TypeError), result converted back to dyn. Callee and - * args are borrowed; the result is owned (+1). MAY THROW. + * args are borrowed; the result is owned (+1). MAY THROW. In a + * dynamic-tier function the jsval pass may make an argument of the + * fixed-arity form a jsval (ir/jsval.ts): a callee with a jsval entry + * takes it as is, any other callee gets it boxed. * * `spreads` (the runtime-arity form — `f(...args)`, the rest-forwarding * idiom): entries name indices into `args` whose dyn values FLATTEN into @@ -3565,7 +3572,8 @@ export type IrExpr = * the kind's prototype lacks throws Node's catchable " is * not a function"; OBJ receivers call the own member (own properties * shadow prototypes in JS too); undefined/null receivers throw Node's - * "Cannot read properties of ...". Arguments are already dyn. + * "Cannot read properties of ...". Arguments are already dyn, or jsvals + * as for dynCall. * `calleeName` is the source spelling for the error texts. Receiver and * args are borrowed; the result is owned (+1). MAY THROW. */ | { diff --git a/packages/compiler/src/ir/js-ic.test.ts b/packages/compiler/src/ir/js-ic.test.ts index 35de18085..eb471da7c 100644 --- a/packages/compiler/src/ir/js-ic.test.ts +++ b/packages/compiler/src/ir/js-ic.test.ts @@ -305,10 +305,18 @@ test("a function holds a site when it calls a classified helper or dispatches a expect(hasJsIcSite(caller, sites)).toBe(true); expect(hasJsIcSite(invoker, sites)).toBe(true); expect(hasJsIcSite(plain, sites)).toBe(false); - // Module initializers keep plain method dispatch (top-level handle - // wiring), while their property sites still cache. + // Module initializers keep plain method dispatch at top level (handle + // wiring), while their property sites still cache, and so do method + // sites in their loops. expect(hasJsIcSite({ ...invoker, name: "%init.0" }, sites)).toBe(false); expect(hasJsIcSite({ ...caller, name: "%init.0" }, sites)).toBe(true); + const looped: IrStmt = { + kind: "while", + cond: { kind: "boolLit", value: false, type: BOOL, loc }, + body: invoker.body, + loc, + }; + expect(hasJsIcSite({ ...invoker, name: "%init.0", body: [looped] }, sites)).toBe(true); }); test("a function holds a site when an object literal has a constant-key member", () => { diff --git a/packages/compiler/src/ir/js-ic.ts b/packages/compiler/src/ir/js-ic.ts index 5a483c585..f4e583cf1 100644 --- a/packages/compiler/src/ir/js-ic.ts +++ b/packages/compiler/src/ir/js-ic.ts @@ -18,13 +18,13 @@ * operation's semantics. This module recognizes the helpers by structure, * never by name alone: a helper whose body is not exactly capsule branches * plus the generic tail is not a site. `dynInvoke` is a site too: its - * method read on an ordinary object is a [[Get]] (outside module - * initializers, see jsIcMethodSites). + * method read on an ordinary object is a [[Get]] (in module initializers, + * only inside loops; see jsIcMethodSites). * * Static functions never contain sites (invariant I1): the backend only * consults this module for calls in dynamic-tier functions. */ import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; -import { everyStmtList } from "./traverse.js"; +import { everyExprChild, everyStmtChild } from "./traverse.js"; export type JsIcSite = | { kind: "get"; key: string } @@ -299,18 +299,33 @@ function unwrapFromDyn(e: IrExpr | null | undefined): IrExpr | null { /** Whether a function holds a cache site: a call to a classified helper, * a method dispatch, or an object literal member with a constant key. */ export function hasJsIcSite(fn: IrFunction, sites: JsIcSites): boolean { - const methods = jsIcMethodSites(fn.name); - return !everyStmtList(fn.body, { - expr: (e) => - !( - (methods && e.kind === "dynInvoke") || - (methods && - e.kind === "dynObjLit" && - (e.fields ?? []).some((f) => jsIcLiteralKey(f.key) !== null)) || - (e.kind === "call" && sites.site(e.callee) !== null) - ), - stmt: () => true, - }); + const site = (e: IrExpr, inLoop: boolean): boolean => + (jsIcMethodSites(fn.name, inLoop) && + (e.kind === "dynInvoke" || + (e.kind === "dynObjLit" && + (e.fields ?? []).some((f) => jsIcLiteralKey(f.key) !== null)))) || + (e.kind === "call" && sites.site(e.callee) !== null); + const expr = (e: IrExpr, inLoop: boolean): boolean => + !site(e, inLoop) && + everyExprChild( + e, + (child) => expr(child, inLoop), + (child) => stmt(child, inLoop), + ); + const stmt = (s: IrStmt, inLoop: boolean): boolean => { + const inner = + inLoop || + s.kind === "while" || + s.kind === "doWhile" || + s.kind === "for" || + s.kind === "forOf"; + return everyStmtChild( + s, + (child) => expr(child, inner), + (child) => stmt(child, inner), + ); + }; + return !fn.body.every((s) => stmt(s, false)); } /** The key of an object literal member that its site adds through the @@ -318,18 +333,19 @@ export function hasJsIcSite(fn: IrFunction, sites: JsIcSites): boolean { * literal starts as an empty object with the default prototype, so each * such member appends one data property, and the site caches the shape * transition and shares the key bytes the shape holds. Module - * initializers have no literal sites (jsIcMethodSites). */ + * initializers have literal sites only in loops (jsIcMethodSites). */ export function jsIcLiteralKey(key: IrExpr): string | null { return key.kind === "strLit" && key.value !== "__proto__" ? key.value : null; } -/** Whether method dispatch in a function goes through caches. Module - * initializers (`%init.N`) run once, and the methods they call at top level - * are overwhelmingly registrations on native handles (`worker.on(...)`, - * `server.listen(...)`), which a cache never answers: they keep the plain - * dispatch, so a typed program whose only dynamic code is such top-level - * wiring links no dynamic-tier runtime. Property sites still cache there - * (top-level loops over objects). */ -export function jsIcMethodSites(fnName: string): boolean { - return !fnName.startsWith("%init."); +/** Whether method dispatch at a site of a function goes through caches. + * Module initializers (`%init.N`) run once, and the methods they call at + * top level are overwhelmingly registrations on native handles + * (`worker.on(...)`, `server.listen(...)`), which a cache never answers: + * they keep the plain dispatch, so a typed program whose only dynamic code + * is such top-level wiring links no dynamic-tier runtime. A site inside a + * loop of an initializer runs once per iteration, so it caches. Property + * sites cache everywhere. */ +export function jsIcMethodSites(fnName: string, inLoop: boolean): boolean { + return inLoop || !fnName.startsWith("%init."); } diff --git a/packages/compiler/src/ir/jsval.test.ts b/packages/compiler/src/ir/jsval.test.ts index d2eadf18d..9435e6436 100644 --- a/packages/compiler/src/ir/jsval.test.ts +++ b/packages/compiler/src/ir/jsval.test.ts @@ -316,8 +316,12 @@ test("a parameter that callers pass numbers and operator results takes a jsval", }); test("a parameter read by dyn consumers or passed cells keeps the dyn ABI", () => { - // keyed(o) { return o.x }: the read needs a cell, so a jsval would box. - const keyed = fn("keyed", [{ kind: "return", value: keyGet(ref("o"), "x"), loc }]); + // keyed(o) { o.y; return o.x }: both reads need a cell, so a jsval would + // box twice where the caller boxes once. + const keyed = fn("keyed", [ + { kind: "exprStmt", expr: keyGet(ref("o"), "y"), loc }, + { kind: "return", value: keyGet(ref("o"), "x"), loc }, + ]); // half(o) { return o / 2 }, called with property reads only. const half = fn("half", [ { kind: "return", value: op("dyn.arithmetic", ref("o"), num(2), "/"), loc }, @@ -407,3 +411,93 @@ test("a module global a static function reads stays dyn", () => { ]); expect(validateModule(mod)).toEqual([]); }); + +test("a class member that dynamic code calls directly gets a jsval entry", () => { + // %Counter.scale(o) { return o * 2 }: vtables and the runtime reach the + // member by name, so it keeps the dyn ABI as a wrapper. + const member = fn("%Counter.scale", [ + { kind: "return", value: op("dyn.arithmetic", ref("o"), num(2), "*"), loc }, + ]); + member.sourceName = "scale"; + const caller = fn("caller", [ + { kind: "return", value: op("dyn.add", call("%Counter.scale", num(1.5)), num(1)), loc }, + ]); + const staticCaller = fn("staticCaller", [ + { kind: "exprStmt", expr: call("%Counter.scale", ref("o")), loc }, + { kind: "return", value: ref("o"), loc }, + ]); + const mod = moduleOf(member, caller, staticCaller); + promoteJsvals(mod); + const entry = mod.functions.find((f) => f.name === "%js.%Counter.scale")!; + expect(entry.params[0]!.type).toEqual(JSVAL); + expect(entry.returnType).toEqual(JSVAL); + expect(entry.sourceName).toBe("scale"); + expect(jsCalls(entry.body)).toEqual(["js.arithmetic", "js.fromF64"]); + // The member forwards its cell and boxes the entry's result. + expect(member.params[0]!.type).toEqual(DYN); + expect(member.returnType).toEqual(DYN); + expect(member.sourceName).toBeUndefined(); + expect(member.body).toHaveLength(1); + expect(jsCalls(member.body)).toEqual(["js.toDyn", "js.fromDyn"]); + // Dynamic callers call the entry; static callers keep the member. + expect(JSON.stringify(caller.body)).toContain('"callee":"%js.%Counter.scale"'); + expect(JSON.stringify(staticCaller.body)).toContain('"callee":"%Counter.scale"'); + expect(validateModule(mod)).toEqual([]); +}); + +test("a closure boxed into a function value gets a jsval body, and dynamic calls pass jsvals", () => { + // %fn.add(o) { return o + 1 }, boxed by a dynamic function and called + // through dynInvoke with an operator result. + const closure = fn("%fn.add", [{ kind: "return", value: op("dyn.add", ref("o"), num(1)), loc }]); + closure.captures = []; + // %fn.read(o) { return o.x }: a property read needs the cell. + const reader = fn("%fn.read", [{ kind: "return", value: keyGet(ref("o"), "x"), loc }]); + reader.captures = []; + const funcType = { kind: "func" as const, params: [DYN], ret: DYN }; + const box = (name: string): IrExpr => ({ + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcType, loc }, + type: DYN, + loc, + }); + const user = fn("user", [ + { kind: "exprStmt", expr: box("%fn.add"), loc }, + { kind: "exprStmt", expr: box("%fn.read"), loc }, + { + kind: "return", + value: { + kind: "dynInvoke", + recv: ref("o"), + method: "add", + calleeName: "o.add", + args: [op("dyn.add", keyGet(ref("o"), "n"), num(1)), ref("o")], + type: DYN, + loc, + }, + loc, + }, + ]); + const mod = moduleOf(closure, reader, user); + promoteJsvals(mod); + expect(closure.jsEntry).toBe("%js.%fn.add"); + expect(reader.jsEntry).toBeUndefined(); + const entry = mod.functions.find((f) => f.name === "%js.%fn.add")!; + expect(entry.captures).toEqual([]); + expect(entry.params[0]!.type).toEqual(JSVAL); + expect(entry.returnType).toEqual(JSVAL); + // The closure keeps its dyn body for every other caller. + expect(closure.params[0]!.type).toEqual(DYN); + expect(closure.returnType).toEqual(DYN); + // The operator result crosses unboxed; the cell argument stays dyn. + let invoke: Extract | undefined; + const find = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(find); + else if (value !== null && typeof value === "object") { + if ((value as IrExpr).kind === "dynInvoke") invoke = value as typeof invoke; + Object.values(value).forEach(find); + } + }; + find(user.body); + expect(invoke!.args.map((a) => a.type.kind)).toEqual(["jsval", "dyn"]); + expect(validateModule(mod)).toEqual([]); +}); diff --git a/packages/compiler/src/ir/jsval.ts b/packages/compiler/src/ir/jsval.ts index fad4a55c7..cea14e446 100644 --- a/packages/compiler/src/ir/jsval.ts +++ b/packages/compiler/src/ir/jsval.ts @@ -33,6 +33,15 @@ * - a dyn local becomes a jsval local when the boxing it saves on writes * of operator results outweighs the boxing its reads by dyn consumers add * (loop depth weights both); + * - a function that dynamic-tier functions call directly but that is also + * reached otherwise (a class member, a function value, a static caller) + * gets a jsval entry `%js.` holding its body when one of its + * parameters qualifies the same way: the direct calls of dynamic-tier + * functions call the entry, and the function itself becomes a wrapper + * that keeps the dyn ABI for everyone else; + * - a closure whose body uses a parameter only as an operand gets a jsval + * copy of its body (IrFunction.jsEntry), which the function values boxed + * from it enter with jsval arguments from dynCall and dynInvoke sites; * - a dyn module global becomes a jsval global by the same measure, summed * over every function, when only dynamic-tier functions reference it. * @@ -47,14 +56,25 @@ * Async functions and generators are rewritten like other functions (their * suspensions keep jsvals like any pointer) but keep the dyn result ABI. */ import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; -import { DYN, JSVAL, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { + DYN, + JSVAL, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrParam, + type IrStmt, +} from "./ir.js"; import { JsIcSites } from "./js-ic.js"; +import { reparseStmt } from "./serialize.js"; import { everyExprChild, everyStmtChild, everyStmtList, mapExprChildren, mapStmtChildren, + transformStmtList, } from "./traverse.js"; /** dyn operators whose result is a fresh dynamic value, with their jsval @@ -117,11 +137,18 @@ export function promoteJsvals(mod: IrModule): number { const sites = new JsIcSites(mod); const storesJs = (e: IrExpr): boolean => e.kind === "call" && sites.site(e.callee)?.kind === "set"; - const params = jsvalParams(mod, direct, results, storesJs); - const globals = promotableGlobals(mod, results, params, storesJs); + const candidates = entryCandidates(mod, direct, sites); + const params = jsvalParams(mod, direct, candidates, results, sites, storesJs); + splitEntries(mod, candidates, params, results); + const jsDynArguments = closureEntries(mod, params, results, storesJs); + const globals = promotableGlobals(mod, results, params, storesJs, jsDynArguments); let count = 0; for (const fn of mod.functions) - if (jsvalEligible(fn) && promoteFunction(fn, results, params, storesJs, globals)) count++; + if ( + jsvalEligible(fn) && + promoteFunction(fn, results, params, storesJs, globals, jsDynArguments) + ) + count++; for (const fn of mod.functions) if (results.has(fn.name)) fn.returnType = JSVAL; for (const global of mod.globals ?? []) if (globals.has(global.id)) global.type = JSVAL; return count; @@ -162,60 +189,279 @@ function directCallees(mod: IrModule): Set { return results; } -/** The parameters of direct callees that take a jsval, by position. A dyn - * parameter of a direct callee other than a library helper (`%dyn.*`, - * whose calls the inline caches emit) is a candidate. Its credit is the - * share of its arguments, weighted by the callers' loop depth, that the - * callers compute as jsvals (operator results, jsval results and static - * numbers): the boxing a call saves. promotableLocals weighs it against - * the callee's reads of the parameter by dyn consumers. */ +/** Functions that dynamic-tier functions call directly but that are also + * reached otherwise, so they keep the dyn ABI: candidates for a jsval + * entry (splitEntries). Library helpers (`%dyn.*`), inline-cache sites, the + * entry, closures (`captures`, `selfRef`) and suspending functions are + * left out. */ +function entryCandidates( + mod: IrModule, + direct: ReadonlySet, + sites: JsIcSites, +): Set { + const called = new Set(); + for (const fn of mod.functions) { + if (fn.tier !== "dynamic") continue; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "call") called.add(e.callee); + return true; + }, + stmt: () => true, + }); + } + const candidates = new Set(); + for (const fn of mod.functions) { + if (!called.has(fn.name) || direct.has(fn.name) || fn.name === mod.entry) continue; + if (!jsvalEligible(fn) || fn.async !== undefined || fn.generator !== undefined) continue; + if (fn.captures !== undefined || fn.classCaptures !== undefined) continue; + if (fn.speculativeDispatch !== undefined || fn.fastRead !== undefined) continue; + if (fn.name.startsWith("%dyn.") || sites.site(fn.name) !== null) continue; + if (!fn.params.some((p) => p.type.kind === "dyn")) continue; + const plain = everyStmtList(fn.body, { expr: (e) => e.kind !== "selfRef", stmt: () => true }); + if (plain) candidates.add(fn.name); + } + return candidates; +} + +/** The parameters that take a jsval, by function and position: parameters + * of direct callees and entry candidates, other than inline-cache sites. + * A parameter's credit is the share of its arguments, weighted by the + * callers' loop depth, that the callers compute as jsvals (operator + * results, jsval results, static numbers and the callers' own jsval + * parameters): the boxing a call saves. promotableLocals weighs it against + * the callee's reads of the parameter by dyn consumers. + * + * The decision starts from every candidate parameter and drops the ones + * whose credit no longer pays, until nothing changes, so a parameter that + * a dispatch helper forwards to a method can qualify in both. */ function jsvalParams( mod: IrModule, direct: ReadonlySet, + candidates: ReadonlySet, results: ReadonlySet, + sites: JsIcSites, storesJs: (e: IrExpr) => boolean, ): Map { const byName = new Map(); - const passed = new Map(); + const params = new Map(); for (const fn of mod.functions) { byName.set(fn.name, fn); - if (!direct.has(fn.name) || fn.name.startsWith("%dyn.")) continue; - if (!fn.params.some((p) => p.type.kind === "dyn")) continue; - passed.set(fn.name, { total: fn.params.map(() => 0), js: fn.params.map(() => 0) }); - } - const params = new Map(); - if (passed.size === 0) return params; - const jsArgument = (e: IrExpr): boolean => - isProducerCall(e) || isImmediateSource(e) || (e.kind === "call" && results.has(e.callee)); - for (const fn of mod.functions) { - if (fn.tier !== "dynamic") continue; - everyWeightedExpr(fn.body, (e, weight) => { - const counts = e.kind === "call" ? passed.get(e.callee) : undefined; - if (counts === undefined || e.kind !== "call") return; - e.args.forEach((arg, i) => { - if (i >= counts.total.length || arg.type.kind !== "dyn") return; - counts.total[i]! += weight; - if (jsArgument(arg)) counts.js[i]! += weight; - }); - }); + if (!direct.has(fn.name) && !candidates.has(fn.name)) continue; + if (sites.site(fn.name) !== null) continue; + const js = fn.params.map((p) => p.type.kind === "dyn"); + if (js.includes(true)) params.set(fn.name, js); } + const returnsJs = (callee: string): boolean => + results.has(callee) || + (candidates.has(callee) && byName.get(callee)!.returnType.kind === "dyn"); const producer = (e: IrExpr): boolean => - isProducerCall(e) || (e.kind === "call" && results.has(e.callee)); - for (const [name, counts] of passed) { - const fn = byName.get(name)!; - const credits = new Map(); - fn.params.forEach((p, i) => { - if (p.type.kind === "dyn" && counts.js[i]! > 0) - credits.set(p.localId, counts.js[i]! / counts.total[i]!); - }); - if (credits.size === 0) continue; - const promoted = promotableLocals(fn, producer, results.has(name), storesJs, credits); - const js = fn.params.map((p) => credits.has(p.localId) && promoted.has(p.localId)); - if (js.includes(true)) params.set(name, js); + isProducerCall(e) || (e.kind === "call" && returnsJs(e.callee)); + for (let changed = params.size > 0; changed;) { + changed = false; + const passed = new Map(); + for (const [name, js] of params) + passed.set(name, { total: js.map(() => 0), js: js.map(() => 0) }); + for (const fn of mod.functions) { + if (fn.tier !== "dynamic") continue; + const own = params.get(fn.name); + const jsLocals = new Set(fn.params.filter((_, i) => own?.[i] === true).map((p) => p.localId)); + const jsArgument = (e: IrExpr): boolean => + producer(e) || isImmediateSource(e) || (e.kind === "varRef" && jsLocals.has(e.localId)); + everyWeightedExpr(fn.body, (e, weight) => { + const counts = e.kind === "call" ? passed.get(e.callee) : undefined; + if (counts === undefined || e.kind !== "call") return; + e.args.forEach((arg, i) => { + if (i >= counts.total.length || arg.type.kind !== "dyn") return; + counts.total[i]! += weight; + if (jsArgument(arg)) counts.js[i]! += weight; + }); + }); + } + for (const [name, js] of params) { + const fn = byName.get(name)!; + const counts = passed.get(name)!; + const credits = new Map(); + fn.params.forEach((p, i) => { + if (js[i] === true && counts.js[i]! > 0) + credits.set(p.localId, counts.js[i]! / counts.total[i]!); + }); + const promoted = + credits.size === 0 + ? new Set() + : promotableLocals(fn, producer, returnsJs(name), storesJs, credits, params); + const next = fn.params.map((p, i) => js[i] === true && promoted.has(p.localId)); + if (next.every((v, i) => v === js[i])) continue; + changed = true; + if (next.includes(true)) params.set(name, next); + else params.delete(name); + } } return params; } +/** Give each entry candidate with a jsval parameter its jsval entry: a new + * function `%js.` takes the body (its stack frame included), the + * function keeps its name and dyn ABI as a wrapper that calls the entry, + * and the direct calls of dynamic-tier functions call the entry instead. + * Static callers, vtables, function values and the runtime keep reaching + * the wrapper. The decisions in `params` and `results` move to the + * entries. */ +function splitEntries( + mod: IrModule, + candidates: ReadonlySet, + params: Map, + results: Set, +): void { + const entries = new Map(); + for (const name of candidates) { + const js = params.get(name); + if (js === undefined) continue; + const entry = `%js.${name}`; + entries.set(name, entry); + params.delete(name); + params.set(entry, js); + } + if (entries.size === 0) return; + const functions: IrFunction[] = []; + for (const fn of mod.functions) { + const entryName = entries.get(fn.name); + if (entryName === undefined) { + functions.push(fn); + continue; + } + const { ownsPrototype: _ownsPrototype, ...rest } = fn; + const entry: IrFunction = { + ...rest, + name: entryName, + params: fn.params.map((p) => ({ ...p })), + }; + if (fn.returnType.kind === "dyn") results.add(entryName); + const call: IrExpr = { + kind: "call", + callee: entryName, + args: fn.params.map((p) => ({ + kind: "varRef", + localId: p.localId, + type: p.type, + loc: fn.loc, + })), + type: fn.returnType, + loc: fn.loc, + }; + fn.locals = fn.params.map((p) => ({ + id: p.localId, + name: p.name, + type: p.type, + mutable: false, + })); + fn.body = + fn.returnType.kind === "void" + ? [{ kind: "exprStmt", expr: call, loc: fn.loc }] + : [{ kind: "return", value: call, loc: fn.loc }]; + delete fn.sourceName; + functions.push(fn, entry); + } + mod.functions = functions; + const wrappers = new Set(entries.keys()); + const retarget = (e: IrExpr): IrExpr => { + const entry = e.kind === "call" ? entries.get(e.callee) : undefined; + return entry === undefined ? e : { ...(e as Extract), callee: entry }; + }; + for (const fn of mod.functions) { + if (fn.tier !== "dynamic" || wrappers.has(fn.name)) continue; + fn.body = transformStmtList(fn.body, { expr: retarget, stmt: (s) => s }); + } +} + +/** Give closures a jsval entry for the function values boxed from them: + * `%js.`, a copy of the body with the same captures whose + * parameters that the body only uses as operands take jsvals, named by + * the closure's `jsEntry`. The backend's box builders register the entry + * on every function value of the closure, and the dynamic tier's dynCall + * and dynInvoke sites call it with jsval arguments. A closure keeps one + * body when no parameter qualifies, when a parameter is typed, when its + * body is not dynamic-tier, + * when a closure of it reads arguments past its parameters (a + * rest-marked type), or when it suspends. Returns whether any closure got + * an entry. */ +function closureEntries( + mod: IrModule, + params: Map, + results: Set, + storesJs: (e: IrExpr) => boolean, +): boolean { + const boxed = new Set(); + const variadic = new Set(); + for (const fn of mod.functions) { + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "closure") { + if (e.type.kind === "func" && e.type.rest !== true) boxed.add(e.fnName); + else variadic.add(e.fnName); + } + return true; + }, + stmt: () => true, + }); + } + for (const name of variadic) boxed.delete(name); + if (boxed.size === 0) return false; + const producer = (e: IrExpr): boolean => + isProducerCall(e) || (e.kind === "call" && results.has(e.callee)); + const functions: IrFunction[] = []; + let any = false; + for (const fn of mod.functions) { + functions.push(fn); + if (!boxed.has(fn.name) || fn.captures === undefined || fn.classCaptures !== undefined) + continue; + if (!jsvalEligible(fn) || fn.async !== undefined || fn.generator !== undefined) continue; + // The jsval entry passes words: a typed parameter would need the dyn + // entry's argument checks. + if (fn.params.some((p) => p.type.kind !== "dyn")) continue; + // A caller of a function value passes no argument the body could not + // take as a cell, so a parameter takes a jsval only when no dyn + // consumer reads it: the half credit loses to any such read. + const credits = new Map(); + for (const p of fn.params) if (p.type.kind === "dyn") credits.set(p.localId, 0.5); + if (credits.size === 0) continue; + const jsResult = fn.returnType.kind === "dyn"; + const promoted = promotableLocals(fn, producer, jsResult, storesJs, credits, params, true); + const js = fn.params.map((p) => credits.has(p.localId) && promoted.has(p.localId)); + if (!js.includes(true)) continue; + const name = `%js.${fn.name}`; + const { ownsPrototype: _ownsPrototype, ...rest } = fn; + functions.push({ + ...rest, + name, + params: fn.params.map((p: IrParam): IrParam => ({ ...p })), + locals: fn.locals.map(copyLocal), + captures: fn.captures.map((c: IrParam): IrParam => ({ ...c })), + body: fn.body.map(reparseStmt), + }); + fn.jsEntry = name; + params.set(name, js); + if (jsResult) results.add(name); + any = true; + } + mod.functions = functions; + return any; +} + +/** A local's own copy, for a body copy whose rewrite retypes locals. */ +function copyLocal(local: IrLocal): IrLocal { + const copy: IrLocal = { + id: local.id, + name: local.name, + type: local.type, + mutable: local.mutable, + }; + if (local.source !== undefined) copy.source = local.source; + if (local.boxed !== undefined) copy.boxed = local.boxed; + if (local.tdz !== undefined) copy.tdz = local.tdz; + return copy; +} + /** Visit every expression of a body with its loop weight. */ function everyWeightedExpr(body: IrStmt[], visit: (e: IrExpr, weight: number) => void): void { const expr = (e: IrExpr, weight: number): void => { @@ -268,7 +514,9 @@ interface LocalCost { /** The dyn locals of a function that become jsval locals. `producer` * tells which dyn expressions the rewrite computes as jsvals. Parameters * are candidates only with a credit (jsvalParams); `jsParams` lists the - * arguments that calls pass as jsvals. */ + * arguments that calls pass as jsvals, `jsDynArguments` whether dynCall + * and dynInvoke take jsval arguments (closureEntries), and `jsBindings` + * the module globals that hold jsvals. */ function promotableLocals( fn: IrFunction, producer: (e: IrExpr) => boolean, @@ -276,14 +524,41 @@ function promotableLocals( storesJs: (e: IrExpr) => boolean, credits: ReadonlyMap = new Map(), jsParams: ReadonlyMap = new Map(), + jsDynArguments = false, + jsBindings: ReadonlySet = new Set(), ): Set { - const candidates = localCandidates(fn, credits); - if (candidates.size === 0) return new Set(); - const blocked = new Set(); - scanBindingCosts(fn, candidates, blocked, producer, jsResult, storesJs, jsParams); - const promoted = new Set(); - for (const [id, cost] of candidates) - if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); + /* One round: `sources` are the bindings taken as jsvals, so a copy of + * one is a jsval write, and the copy's read of it a jsval use. */ + const round = (sources: ReadonlySet): Set => { + const candidates = localCandidates(fn, credits); + if (candidates.size === 0) return new Set(); + const blocked = new Set(); + scanBindingCosts(fn, candidates, blocked, producer, jsResult, storesJs, jsParams, { + sources, + jsDynArguments, + }); + const promoted = new Set(); + // A caller boxes on every call, the callee only on the paths that read + // the parameter by a dyn consumer: a parameter takes a jsval on a tie. + for (const [id, cost] of candidates) + if ( + !blocked.has(id) && + (cost.saved > cost.added || (credits.has(id) && cost.saved >= cost.added)) + ) + promoted.add(id); + return promoted; + }; + // Each round takes the previous round's promotions as sources, so a copy + // chain from a promoted local, a credited parameter or a jsval global + // follows it. + let sources = new Set([...credits.keys(), ...jsBindings]); + let promoted = round(sources); + for (let i = 0; i < 4; i++) { + const next = new Set([...sources, ...promoted]); + if (next.size === sources.size) break; + sources = next; + promoted = round(sources); + } return promoted; } @@ -319,6 +594,7 @@ function promotableGlobals( results: ReadonlySet, jsParams: ReadonlyMap, storesJs: (e: IrExpr) => boolean, + jsDynArguments: boolean, ): Set { const candidates = new Map(); if (mod.lib !== undefined) return new Set(); @@ -349,7 +625,10 @@ function promotableGlobals( }); continue; } - scanBindingCosts(fn, candidates, blocked, producer, results.has(fn.name), storesJs, jsParams); + scanBindingCosts(fn, candidates, blocked, producer, results.has(fn.name), storesJs, jsParams, { + sources: new Set(), + jsDynArguments, + }); } const promoted = new Set(); for (const [id, cost] of candidates) @@ -359,7 +638,9 @@ function promotableGlobals( /** Adds to `candidates` the boxing a jsval binding saves and adds in `fn`, * and to `blocked` the candidates `fn` references other than by reads and - * plain writes. */ + * plain writes. `sources` are bindings taken as jsvals: writing one to a + * candidate saves a box, and the copy reads it as a jsval. With + * `jsDynArguments`, dynCall and dynInvoke take jsval arguments. */ function scanBindingCosts( fn: IrFunction, candidates: ReadonlyMap, @@ -368,6 +649,7 @@ function scanBindingCosts( jsResult: boolean, storesJs: (e: IrExpr) => boolean, jsParams: ReadonlyMap, + { sources, jsDynArguments }: { sources: ReadonlySet; jsDynArguments: boolean }, ): void { const write = (id: string, value: IrExpr | null, weight: number): void => { const cost = candidates.get(id); @@ -377,7 +659,12 @@ function scanBindingCosts( blocked.add(id); return; } - if (producer(value) || isImmediateSource(value)) cost.saved += weight; + if ( + producer(value) || + isImmediateSource(value) || + (value.kind === "varRef" && sources.has(value.localId)) + ) + cost.saved += weight; }; /* `js`: the expression is an operand the rewrite turns into a jsval. */ const expr = (e: IrExpr, js: boolean, weight: number): void => { @@ -389,7 +676,7 @@ function scanBindingCosts( } case "assignExpr": write(e.localId, e.value, weight); - expr(e.value, false, weight); + expr(e.value, sources.has(e.localId), weight); // The assignment's own value is read where it stands. if (!js && candidates.has(e.localId)) candidates.get(e.localId)!.added += weight; return; @@ -425,6 +712,18 @@ function scanBindingCosts( } break; } + case "dynCall": + case "dynInvoke": { + const jsArgs = jsDynArguments && (e.kind === "dynInvoke" || e.spreads === undefined); + for (const arg of e.args) expr(arg, jsArgs, weight); + if (e.kind === "dynInvoke") expr(e.recv, false, weight); + else { + expr(e.callee, false, weight); + if (e.receiver !== undefined) expr(e.receiver, false, weight); + } + if (e.calleeNameValue !== undefined) expr(e.calleeNameValue, false, weight); + return; + } default: break; } @@ -470,12 +769,12 @@ function scanBindingCosts( return; case "varDecl": write(s.localId, s.init, weight); - if (s.init !== null) expr(s.init, false, weight); + if (s.init !== null) expr(s.init, sources.has(s.localId), weight); return; case "assign": if (s.initializes !== undefined) blocked.add(s.localId); write(s.localId, s.value, weight); - expr(s.value, false, weight); + expr(s.value, sources.has(s.localId), weight); return; case "while": case "doWhile": @@ -527,19 +826,28 @@ function promoteFunction( params: ReadonlyMap, storesJs: (e: IrExpr) => boolean, globals: ReadonlySet, + jsDynArguments: boolean, ): boolean { const resultCall = (e: IrExpr): boolean => e.kind === "call" && results.has(e.callee); const operatorCall = (e: IrExpr): boolean => isProducerCall(e); + const ownParams = params.get(fn.name); + // The function's own jsval parameters are sources for the copies the + // rewrite makes jsval locals. + const own = new Map(); + fn.params.forEach((p, i) => { + if (ownParams?.[i] === true) own.set(p.localId, 1); + }); const promoted = promotableLocals( fn, (e) => operatorCall(e) || resultCall(e), results.has(fn.name), storesJs, - new Map(), + own, params, + jsDynArguments, + globals, ); for (const id of globals) promoted.add(id); - const ownParams = params.get(fn.name); fn.params.forEach((p, i) => { if (ownParams?.[i] === true) promoted.add(p.localId); }); @@ -606,6 +914,25 @@ function promoteFunction( changed = true; return { ...e, args: callArgs(e) }; } + // A callee with a jsval entry takes the arguments the rewrite computes + // as jsvals unboxed; the call boxes them for any other callee. + if ( + jsDynArguments && + (e.kind === "dynInvoke" || (e.kind === "dynCall" && e.spreads === undefined)) + ) { + const args = e.args.map((arg) => + producesJs(arg) || isImmediateSource(arg) ? toJs(arg) : rewrite(arg), + ); + if (e.kind === "dynInvoke") { + const invoke = { ...e, recv: rewrite(e.recv), args }; + if (e.calleeNameValue !== undefined) invoke.calleeNameValue = rewrite(e.calleeNameValue); + return invoke; + } + const call = { ...e, callee: rewrite(e.callee), args }; + if (e.receiver !== undefined) call.receiver = rewrite(e.receiver); + if (e.calleeNameValue !== undefined) call.calleeNameValue = rewrite(e.calleeNameValue); + return call; + } return mapExprChildren(e, rewrite, rewriteStmt); }; const rewriteStmt = (s: IrStmt): IrStmt => { diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 28677f3ec..630301676 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1424,6 +1424,13 @@ function scanRuntimeFeatures( const sites = new JsIcSites(mod); features.dynTier = mod.functions.some((fn) => fn.tier === "dynamic" && hasJsIcSite(fn, sites)); } + // A jsval parameter or result (ir/jsval.ts) is retained and released + // through the tier's word reference counting, with or without a js.* + // operation in the body. + if (!features.dynTier) + features.dynTier = mod.functions.some( + (fn) => fn.returnType.kind === "jsval" || fn.params.some((p) => p.type.kind === "jsval"), + ); return features; } diff --git a/packages/compiler/src/ir/self-captures.test.ts b/packages/compiler/src/ir/self-captures.test.ts index bd03725aa..237012b77 100644 --- a/packages/compiler/src/ir/self-captures.test.ts +++ b/packages/compiler/src/ir/self-captures.test.ts @@ -204,3 +204,26 @@ test("mutually recursive closures only drop their own capture", () => { ret(call("a.0")), ]); }); + +test("a closure's jsval body drops the same capture as the closure", () => { + const n = local("n.0", { type: F64 }); + const walk = lifted("walk", [use(ref("n.0", F64)), ret(call("f.0"))], [n, local("f.0")]); + walk.jsEntry = "%js.walk"; + const fns = run( + outer( + [ + decl("n.0", { kind: "numLit", value: 1, type: F64, loc }), + decl("f.0", closure("walk", ["n.0", "f.0"])), + ret(call("f.0")), + ], + [n, local("f.0")], + ), + walk, + lifted("%js.walk", [use(ref("n.0", F64)), ret(call("f.0"))], [n, local("f.0")]), + ); + for (const name of ["walk", "%js.walk"]) { + const fn = fns.get(name)!; + expect(fn.captures!.map((c) => c.localId)).toEqual(["n.0"]); + expect(fn.body[1]).toEqual(ret({ kind: "callValue", callee: self, args: [], type: F64, loc })); + } +}); diff --git a/packages/compiler/src/ir/self-captures.ts b/packages/compiler/src/ir/self-captures.ts index 70242cb8b..771253d7f 100644 --- a/packages/compiler/src/ir/self-captures.ts +++ b/packages/compiler/src/ir/self-captures.ts @@ -118,11 +118,18 @@ export function resolveSelfCaptures(mod: IrModule): IrModule { } } if (resolved.size === 0) return mod; + // A closure's jsval body (IrFunction.jsEntry) runs in the closure's own + // environment, so it drops the same capture. + for (const fn of mod.functions) { + const index = resolved.get(fn.name); + if (index !== undefined && fn.jsEntry !== undefined) resolved.set(fn.jsEntry, index); + } // Only the resolved bodies and the functions that create their closures // change. const touched = new Set(resolved.keys()); - for (const name of resolved.keys()) for (const site of sites.get(name)!) touched.add(site.owner); + for (const name of resolved.keys()) + for (const site of sites.get(name) ?? []) touched.add(site.owner); const rewritten = mod.functions.map((fn) => { if (!touched.has(fn.name)) return fn; const own = resolved.get(fn.name); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f591c6dd4..bbd43d53f 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3528,7 +3528,7 @@ function validateFunction( if (e.type.kind !== "dyn") err(`dynCall must be dyn-typed, got ${e.type.kind}`, e.loc); for (const a of e.args) { checkExpr(a); - if (a.type.kind !== "dyn") + if (a.type.kind !== "dyn" && !(a.type.kind === "jsval" && e.spreads === undefined)) err(`dynCall argument of kind ${a.type.kind} (must be dyn)`, e.loc); } // The runtime-arity form: spread entries point into args (their @@ -3556,7 +3556,7 @@ function validateFunction( if (e.type.kind !== "dyn") err(`dynInvoke must be dyn-typed, got ${e.type.kind}`, e.loc); for (const a of e.args) { checkExpr(a); - if (a.type.kind !== "dyn") + if (a.type.kind !== "dyn" && a.type.kind !== "jsval") err(`dynInvoke argument of kind ${a.type.kind} (must be dyn)`, e.loc); } break; diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index 2ac6143b3..d3462cc82 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -378,7 +378,7 @@ done: expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v47"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v48"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -712,7 +712,7 @@ done: .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v47"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v48"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json new file mode 100644 index 000000000..02bc96de1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8600-dynamic-class-method-numbers.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json new file mode 100644 index 000000000..b51915642 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8601-dynamic-function-value-numbers.js" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index ecb2a901b..7567f9ebb 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -364,7 +364,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" }, + runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 97e03b189..c9b617929 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -533,7 +533,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" }, + runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index f1fefa626..b57c3be90 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v47(void) {} +void scr_runtime_abi_v48(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h index f3c85e740..254cc884f 100644 --- a/packages/runtime/src/scr_core.h +++ b/packages/runtime/src/scr_core.h @@ -81,7 +81,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v47(void); +void scr_runtime_abi_v48(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index f4bc8db68..7e9c00548 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -327,6 +327,12 @@ typedef struct ScrDynTypedCast { * result as an owned (+1) dyn value. `args` entries are BORROWED. */ typedef ScrDyn *(*ScrDynThunk)(ScrClosure *clo, ScrDyn *const *args, size_t argc); +/* A compiled closure's jsval entry (scr_js.h words): calls the closure's + * jsval body with `args` (BORROWED words; a missing argument is undefined) + * and returns the result as an owned word. With an exception pending the + * word is undefined; callers test the exception cell, not the word. */ +typedef void *(*ScrDynJsEntry)(ScrClosure *clo, void *const *args, size_t argc); + /* A compiled 'message' listener's typed entry: builds the closure's * parameter from the message's nodes (the readers below) and calls * the closure. Answers false, without calling it, when the message does @@ -575,7 +581,7 @@ struct ScrDyn { * and `name` are static compiler-emitted literals (never freed); name * may be NULL (anonymous — inspect prints [Function (anonymous)]). * The dyn→closure ownership edge is traced by the cycle collector. */ - struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; ScrDynMessageListener message; } fn; + struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; ScrDynMessageListener message; ScrDynJsEntry js; } fn; /* Native handles normally keep conservative ownership edges. Sets * backed by cycle-capable maps expose their edge to the collector. * Cache that allocation property: during collection the map may be @@ -1783,6 +1789,9 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con /* Gives a function box made with thunk its typed message entry (an * EventEmitter listener registered from it keeps the entry). */ void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener); +/* Gives a function box made with thunk its jsval entry. Emitted call sites + * of the dynamic tier load v.fn.js directly (at byte 72 on 64-bit targets). */ +void scr_dyn_func_set_js(ScrDyn *fn, ScrDynThunk thunk, ScrDynJsEntry entry); /* Sets a freshly boxed function's `length` (a builtin function value whose * length is not its adapter's parameter count). */ void scr_dyn_function_set_length(ScrDyn *fn, uint32_t length); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index bee032446..1ed33c5cd 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -6391,9 +6391,20 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con d->v.fn.arity = clo->identity != NULL ? clo->identity_length : arity; d->v.fn.class_obj = NULL; d->v.fn.message = NULL; + d->v.fn.js = NULL; return d; } +/* The emitted fast path loads the entry at this offset (64-bit targets). */ +_Static_assert(sizeof(void *) != 8 || offsetof(ScrDyn, v.fn.js) == 72, + "ScrDyn.v.fn.js moved: update the dynamic tier's call sites"); + +void scr_dyn_func_set_js(ScrDyn *fn, ScrDynThunk thunk, ScrDynJsEntry entry) { + /* As scr_dyn_func_set_message: an adapted function's original box keeps + * its own entries. */ + if (fn && fn->kind == SCR_DYN_FUNC && fn->v.fn.thunk == thunk) fn->v.fn.js = entry; +} + void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener) { /* scr_dyn_new_func answers an adapted function's original box, whose * closure has other parameter types. */ diff --git a/tests/corpus/8600-dynamic-class-method-numbers.js b/tests/corpus/8600-dynamic-class-method-numbers.js new file mode 100644 index 000000000..e78ca0f88 --- /dev/null +++ b/tests/corpus/8600-dynamic-class-method-numbers.js @@ -0,0 +1,66 @@ +// Untyped code calls methods of compiled classes with numbers: overrides, +// `super` calls, static methods, missing and extra arguments, `arguments`, +// methods taken as values, numeric results and throws. +class Cursor { + constructor(x, y) { + this.x = x; + this.y = y; + } + step(dx, dy) { + this.x += dx; + this.y += dy; + return this; + } + scaled(k) { + return this.x * k + this.y * k; + } + shift(dx, dy = dx * 2) { + return this.x + dx + dy; + } + count() { + return arguments.length + ":" + (arguments[0] ?? "none"); + } + checked(v) { + if (v > 50) throw new RangeError("step too large: " + v); + return v / 2; + } + static mid(a, b) { + return (a + b) / 2; + } +} +class Fast extends Cursor { + step(dx, dy) { + super.step(dx * 2, dy * 2); + return this; + } + scaled(k) { + return super.scaled(k) + 0.25; + } +} +const cursors = [new Cursor(1.5, -2.5), new Fast(0.5, 0.25), new Cursor(1e10, -1e10)]; +let total = 0; +for (let r = 0; r < 300; r++) { + const c = cursors[r % 3]; + c.step((r % 7) * 0.5 - 1, (r % 5) * 1.25 - 2); + total += c.scaled(r % 4 === 0 ? 1.5 : 0.75); + total += c.shift(r * 0.1); + if (Math.abs(c.x) > 1e6 && c.x < 1e9) c.x = 0; +} +console.log(total.toFixed(4)); +for (const c of cursors) console.log(c.constructor.name, c.x, c.y); +console.log(cursors[0].shift(1.5), cursors[0].shift(1.5, 0.5), cursors[1].shift(-0)); +console.log(cursors[0].count(), cursors[0].count(2.5, 3), cursors[1].count(undefined)); +console.log(Cursor.mid(1.5, 2), Cursor.mid(-0, 0), Fast.mid(2 ** 53, 1)); +const fast = new Fast(0.5, 0.25); +const step = fast.step; +console.log(step.call(new Cursor(10.5, 20.5), 1, 2).x, typeof step); +let caught = 0; +for (let i = 45; i < 55; i++) { + try { + total += cursors[0].checked(i * 1.25); + } catch (e) { + if (caught++ === 0) console.log(e instanceof RangeError, e.message); + } +} +console.log(caught, total.toFixed(4)); +console.log(cursors[0].scaled(NaN), cursors[1].scaled(Infinity), Object.is(cursors[0].scaled(-0) * 0, 0)); diff --git a/tests/corpus/8601-dynamic-function-value-numbers.js b/tests/corpus/8601-dynamic-function-value-numbers.js new file mode 100644 index 000000000..81aec82c7 --- /dev/null +++ b/tests/corpus/8601-dynamic-function-value-numbers.js @@ -0,0 +1,92 @@ +// Untyped code calls function values with numbers: object literal +// methods, closures held in arrays and objects, `this`, missing and extra +// arguments, `arguments`, captured bindings, recursion by name, throws, +// callbacks of builtins, bound functions, proxies and non-functions. +import assert from "node:assert"; + +const tally = { + total: 0, + add(value) { + this.total += value; + return this.total; + }, + scaled(value, k) { + return value * (k ?? 2); + }, + count() { + return arguments.length; + }, +}; +let sum = 0; +for (let i = 0; i < 500; i++) sum += tally.add((i % 7) * 0.5) % 11; +console.log(sum, tally.total); +console.log(tally.scaled(1.5), tally.scaled(1.5, 3), tally.scaled(1.5, 3, 99), tally.count(1, 2.5, 3)); + +let captured = 0.25; +const step = (dx) => { + captured += dx; + return captured; +}; +const fns = [step, (v) => v * v, (a, b) => a - b]; +let acc = 0; +for (let i = 0; i < 300; i++) acc += fns[i % 3](i * 0.5, 1.5); +console.log(acc, captured); + +const ops = { + inc: (v) => v + 1, + fact: function fact(n) { + return n <= 1 ? 1 : n * fact(n - 1); + }, +}; +console.log(ops.inc(41.5), ops.fact(10), ops.fact(1.5)); + +const holder = { k: 3, read(v) { return this.k * v; } }; +const read = holder.read; +console.log(holder.read(2.5), read.call({ k: 10 }, 2.5), read.bind({ k: 4 })(2.5)); + +const checked = { + limit(v) { + if (v > 10) throw new RangeError("too large: " + v); + return v; + }, +}; +let caught = 0; +for (let i = 0; i < 20; i++) { + try { + acc += checked.limit(i * 0.75); + } catch (e) { + if (caught++ === 0) console.log(e instanceof RangeError, e.message); + } +} +console.log(caught, acc); + +console.log([1.5, 2.5, 3.5].map((v) => ops.inc(v)).join(","), [1, 2, 3].reduce((a, b) => a + b * 0.5, 0)); +const proxied = new Proxy(ops.inc, { apply: (target, self, args) => target(args[0] * 10) }); +console.log(proxied(2.5)); +class Point {} +const values = { notFn: 5, cls: Point, inc: ops.inc }; +for (const key of ["notFn", "cls", "inc"]) { + try { + console.log(key, values[key](1.5)); + } catch (e) { + console.log(key, e.constructor.name, e.message); + } +} +const big = { mul: (v) => v * 1e308 }; +console.log(big.mul(10), big.mul(-10), Object.is(big.mul(-0), -0), big.mul(NaN)); + + +// A library call that throws inside a function value's body reaches the +// call through the value. +const succeeded = (fn) => + function (err, v) { + assert.ifError(err); + return fn(v * 2); + }; +for (const err of [null, new TypeError("boom"), 0]) { + try { + console.log(succeeded((v) => v + 0.5)(err, 1.25)); + } catch (e) { + console.log(e.message); + } +} diff --git a/tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json b/tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json new file mode 100644 index 000000000..d048a3c1d --- /dev/null +++ b/tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 54700 } diff --git a/tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json b/tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json new file mode 100644 index 000000000..af9dd75c4 --- /dev/null +++ b/tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 10100 } From d478dc9ec5b295f163de67962574e758aa700511 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 07:05:55 -0500 Subject: [PATCH 067/193] Send a zero from an inline RWF_NOWAIT read to the pool Linux 5.9 and 5.10 answer 0 instead of EAGAIN for an RWF_NOWAIT read of data that is not cached. The inline read took that 0 as end of file and ended the ReadStream early. A zero from the inline read now goes to the pool, whose read returns the data or confirms end of file; this costs one pool round trip at the end of each file. fs-stream-nowait.test.ts preloads a preadv2 shim that answers 0 for every RWF_NOWAIT read and checks that a ReadStream still reads the whole file, as Node does. --- packages/runtime/src/scr_fs_stream.c | 5 ++- tests/fixtures/fs-stream-nowait-read.ts | 23 +++++++++++++ tests/fixtures/fs-stream-nowait-zero.c | 13 ++++++++ tests/harness/fs-stream-nowait.test.ts | 44 +++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/fs-stream-nowait-read.ts create mode 100644 tests/fixtures/fs-stream-nowait-zero.c create mode 100644 tests/harness/fs-stream-nowait.test.ts diff --git a/packages/runtime/src/scr_fs_stream.c b/packages/runtime/src/scr_fs_stream.c index 421438b94..ae53f5d4e 100644 --- a/packages/runtime/src/scr_fs_stream.c +++ b/packages/runtime/src/scr_fs_stream.c @@ -186,7 +186,10 @@ static bool scr_fss_try_inline(ScrFssOp *op) { if (errno == EOPNOTSUPP || errno == EINVAL || errno == ENOSYS) f->inline_ok = false; return false; } - if (n == 0 && op->kind == SCR_FSS_WRITE) return false; + /* A zero is not taken as EOF here: some kernels (Linux 5.9 and 5.10) + * answer 0 instead of EAGAIN for an uncached RWF_NOWAIT read, so the + * pool's read decides, as it does for a zero-byte write. */ + if (n == 0) return false; if (op->kind == SCR_FSS_READ && n > 0 && (size_t)n < op->len) { op->got = (size_t)n; return false; diff --git a/tests/fixtures/fs-stream-nowait-read.ts b/tests/fixtures/fs-stream-nowait-read.ts new file mode 100644 index 000000000..5e9fd3739 --- /dev/null +++ b/tests/fixtures/fs-stream-nowait-read.ts @@ -0,0 +1,23 @@ +// Reads a file it writes first through a ReadStream and prints the byte +// count and a checksum (fs-stream-nowait.test.ts runs it with a preadv2 +// shim). +import { createReadStream, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dir = mkdtempSync(join(tmpdir(), "scr-nowait-")); +const file = join(dir, "data.bin"); +const data = Buffer.alloc(300000); +for (let i = 0; i < data.length; i++) data[i] = (i * 13) & 0xff; +writeFileSync(file, data); +let bytes = 0; +let sum = 0; +createReadStream(file, { highWaterMark: 16384 }) + .on("data", (c: Buffer) => { + bytes += c.length; + for (const b of c) sum = (sum + b) % 65521; + }) + .on("end", () => { + console.log(`bytes=${bytes} sum=${sum}`); + rmSync(dir, { recursive: true }); + }); diff --git a/tests/fixtures/fs-stream-nowait-zero.c b/tests/fixtures/fs-stream-nowait-zero.c new file mode 100644 index 000000000..69626997f --- /dev/null +++ b/tests/fixtures/fs-stream-nowait-zero.c @@ -0,0 +1,13 @@ +/* LD_PRELOAD shim for fs-stream-nowait.test.ts: preadv2 with RWF_NOWAIT + * answers 0, as Linux 5.9 and 5.10 do for an uncached RWF_NOWAIT read; + * other calls go to the real preadv2. */ +#define _GNU_SOURCE +#include +#include + +ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags) { + if (flags & RWF_NOWAIT) return 0; + ssize_t (*real)(int, const struct iovec *, int, off_t, int) = + (ssize_t (*)(int, const struct iovec *, int, off_t, int))dlsym(RTLD_NEXT, "preadv2"); + return real(fd, iov, iovcnt, offset, flags); +} diff --git a/tests/harness/fs-stream-nowait.test.ts b/tests/harness/fs-stream-nowait.test.ts new file mode 100644 index 000000000..a6ba9e074 --- /dev/null +++ b/tests/harness/fs-stream-nowait.test.ts @@ -0,0 +1,44 @@ +/* fs streams read a regular file with RWF_NOWAIT on the loop thread first + * (scr_fs_stream.c). Linux 5.9 and 5.10 answer 0 instead of EAGAIN for + * an uncached RWF_NOWAIT read; a preloaded preadv2 shim reproduces that + * answer, and the stream must still read the whole file, as Node does. */ +import { execFileSync, spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +describe.skipIf(process.platform !== "linux" || sanitize)("fs stream RWF_NOWAIT reads", () => { + test("a zero from an RWF_NOWAIT read is not taken as EOF", async () => { + const outDir = join(cacheDir, "fs-stream-nowait"); + const result = await compile(join(repoRoot, "tests/fixtures/fs-stream-nowait-read.ts"), { + outPath: join(outDir, "read"), + outDir, + backend: "llvm", + }); + if (!result.ok) { + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + } + const shim = join(outDir, "nowait-zero.so"); + execFileSync("clang", [ + "-shared", + "-fPIC", + join(repoRoot, "tests/fixtures/fs-stream-nowait-zero.c"), + "-o", + shim, + "-ldl", + ]); + const expected = "bytes=300000 sum=50681\n"; + const plain = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect(plain.stdout).toBe(expected); + const shimmed = spawnSync(result.binaryPath, [], { + encoding: "utf8", + env: { ...process.env, LD_PRELOAD: shim }, + }); + expect(shimmed.status).toBe(0); + expect(shimmed.stdout).toBe(expected); + }); +}); From b421cf6a3804aca0825d4601da238b90c130fd65 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 07:12:59 -0500 Subject: [PATCH 068/193] Pass every argument to emitter listeners that read arguments, and deliver the leak warning at exit --- .../compiler/src/backend/llvm/lib-dispatch.ts | 3 ++ .../compiler/src/backend/llvm/lib-process.ts | 6 +++ .../frontend/lowering/lower-event-emitter.ts | 27 +++++++++++- .../8640-emitter-arguments-listeners.cjs.json | 6 +++ .../8641-emitter-leak-warning-exit.cjs.json | 6 +++ .../8640-emitter-arguments-listeners.cjs | 44 +++++++++++++++++++ .../corpus/8641-emitter-leak-warning-exit.cjs | 18 ++++++++ .../8640-emitter-arguments-listeners.cjs.json | 1 + .../8641-emitter-leak-warning-exit.cjs.json | 1 + 9 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json create mode 100644 tests/corpus/8640-emitter-arguments-listeners.cjs create mode 100644 tests/corpus/8641-emitter-leak-warning-exit.cjs create mode 100644 tests/harness/corpus-costs/8640-emitter-arguments-listeners.cjs.json create mode 100644 tests/harness/corpus-costs/8641-emitter-leak-warning-exit.cjs.json diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 8378fabea..1f4c02f7b 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -214,6 +214,9 @@ export function emitIoLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; + // The generic emitter registrations queue the maxListeners leak warning + // for the next tick like emitter.on (lib-process.ts). + if (e.fn === "emitter.onFlex" || e.fn === "emitter.onAny") host.usesTimers = true; if (host.coroAwait && e.fn === "async.hop") { host.emitCoroAwait(null); return { name: "", type: e.type }; diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 01efd3354..d8b94fac0 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -867,6 +867,10 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr const cbT = e.args[2]!.type; if (cbT.kind !== "func") throw new InternalCompilerError("llvm emitter bug: emitter.on listener not a func"); + // A registration past the emitter's maxListeners queues the leak + // warning for the next tick (scr_events_emitter.c), which the loop + // delivers before the process exits. + host.usesTimers = true; const args = e.args.map((a) => host.emitExpr(a)); const { fn: adapterFn, shim } = host.emitterFixedAdapter(cbT); // The wrapper's capture box owns its OWN +1 of the listener; the @@ -897,6 +901,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr const adT = e.args[3]!.type; if (adT.kind !== "func") throw new InternalCompilerError("llvm emitter bug: emitter.onDyn adapter not a func"); + host.usesTimers = true; // the leak warning's tick (emitter.on) const args = e.args.map((a) => host.emitExpr(a)); const { fn: adapterFn, shim } = host.emitterFixedAdapter(adT); host.toHeap(args[3]!); // the frame's +1 moves into the wrapper's box @@ -1046,6 +1051,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr // emitter.on/onDyn, but the DATA adapter (the three-slot payload ABI // — scr_stream_emit_data) behind the arity-3 fixed shim. const isDyn = e.fn === "emitter.onDataDyn"; + host.usesTimers = true; // the leak warning's tick (emitter.on) const cbT = e.args[isDyn ? 3 : 2]!.type; if (cbT.kind !== "func") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} listener not a func`); diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index f44a3d6d8..be9de0e5a 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -49,6 +49,7 @@ import type { Lowerer } from "./lowerer.js"; import { dynFallbackType, newFnCtx } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; import { isJsSourceFile, locOf } from "../program.js"; +import { bodyReadsArguments } from "../arguments-usage.js"; import { arrayOf, BOOL, @@ -339,11 +340,13 @@ function emitterEvents(lowerer: Lowerer): Map { const cbCt = lowerer.typeOf(node.arguments.nodes[1]); const cbT = lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, node.arguments.nodes[1], cbCt); + const pack = readsArgumentsPack(lowerer, node.arguments.nodes[1]); // A TypeScript listener with no parameters is typed too: it // registers without the adapter, so an untyped emit reaches it // only through the event's fixed ABI. A JavaScript function // without parameters can still read `arguments`. if ( + !pack && cbT?.kind !== "dyn" && (cbT?.kind !== "func" || (cbT.params.length === 0 && !isJsSourceFile(sf)) || @@ -358,7 +361,8 @@ function emitterEvents(lowerer: Lowerer): Map { // undefined (exactly JS's extra-parameter semantics). if ( cbT?.kind === "dyn" || - (cbT?.kind === "func" && cbT.params.some((p) => p.kind === "dyn")) + (cbT?.kind === "func" && cbT.params.some((p) => p.kind === "dyn")) || + pack ) { const sig = sigOf(name); sig.hasListener = true; @@ -714,6 +718,26 @@ function tupleOf( return sig.tuple; } +/** True when the listener is a function whose body reads `arguments`: it + * lowers with an arguments-pack ABI and takes every emitted argument, + * which the typed path's call with a prefix of the event's tuple would not + * pass, so it registers as a checked-dynamic listener. */ +function readsArgumentsPack(lowerer: Lowerer, node: ts.Expression): boolean { + let declaration: ts.Node | undefined; + try { + declaration = lowerer.checker.getCallSignatures(lowerer.typeOf(node))[0]?.declaration; + } catch { + return false; + } + return ( + declaration !== undefined && + ts.isFunctionLike(declaration) && + !ts.isArrowFunction(declaration) && + isJsSourceFile(ts.getSourceFileOfNode(declaration)) && + bodyReadsArguments(declaration) + ); +} + /** How a listener argument is dyn-flavored: "dyn" for a checked-dynamic * VALUE (a mustCall wrapper, a listener that rode an untyped binding), * "func" for a function whose parameters include dyn (the JS lane's @@ -734,6 +758,7 @@ function dynListenerFlavor(lowerer: Lowerer, node: ts.Expression): "dyn" | "func if (t?.kind === "dyn") return "dyn"; if (t?.kind === "func") { if (t.params.some((p) => p.kind === "dyn")) return "func"; + if (readsArgumentsPack(lowerer, node)) return "func"; // The inferred signature of a wrapper can omit its runtime arguments // pack. Register the checked callable produced by the factory itself. return tryLowerExpression(lowerer, node)?.type.kind === "dyn" ? "dyn" : null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json new file mode 100644 index 000000000..8a0867122 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8640-emitter-arguments-listeners.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json new file mode 100644 index 000000000..5c4ad7909 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8641-emitter-leak-warning-exit.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/8640-emitter-arguments-listeners.cjs b/tests/corpus/8640-emitter-arguments-listeners.cjs new file mode 100644 index 000000000..db0d5b6d6 --- /dev/null +++ b/tests/corpus/8640-emitter-arguments-listeners.cjs @@ -0,0 +1,44 @@ +// Listeners that read `arguments` receive every emitted argument, whether +// the event's other listeners and emits are typed or untyped: a function +// whose body reads `arguments` takes the whole argument list, not a +// prefix of the event's tuple. +const { EventEmitter } = require("events"); + +const e = new EventEmitter(); +const counts = []; +e.on("numArgs", function () { + counts.push(arguments.length); +}); +e.on("foo", function () { + counts.push(arguments.length); +}); +e.on("foo", function () { + const n = arguments.length; + counts.push(n); +}); +e.emit("numArgs"); +e.emit("numArgs", null); +e.emit("numArgs", null, null); +e.emit("numArgs", null, null, null, null, null); +e.emit("foo", null, null, null, null); +console.log(counts.join(",")); + +// A listener reading its arguments by index, next to one with parameters. +const f = new EventEmitter(); +f.on("pair", (a, b) => console.log("params", a, b)); +f.on("pair", function () { + console.log("arguments", arguments.length, arguments[0], arguments[1], arguments[2]); +}); +f.emit("pair", 1, "two"); +f.emit("pair", 3, "four", true); + +// A once listener reading arguments, and a listener added during an emit. +const g = new EventEmitter(); +g.once("tick", function () { + console.log("once", Array.prototype.slice.call(arguments).join("|")); + g.on("tick", function () { + console.log("later", arguments.length); + }); +}); +g.emit("tick", "a", "b", "c"); +g.emit("tick", "d"); diff --git a/tests/corpus/8641-emitter-leak-warning-exit.cjs b/tests/corpus/8641-emitter-leak-warning-exit.cjs new file mode 100644 index 000000000..1c484c481 --- /dev/null +++ b/tests/corpus/8641-emitter-leak-warning-exit.cjs @@ -0,0 +1,18 @@ +// @exit: 1 +// The MaxListenersExceededWarning of an emitter that passes its limit is +// delivered before the process exits, also in a program with no other +// asynchronous work, once per event name. The 'warning' listener prints it +// (the stderr report carries the pid, so the @exit directive keeps stderr +// out of the comparison). +process.on("warning", (w) => console.log("warning:", w.name, "|", w.message)); +const { EventEmitter } = require("events"); + +const e = new EventEmitter(); +e.setMaxListeners(1); +e.on("event-type", () => {}); +e.on("event-type", () => {}); +e.on("event-type", () => {}); +e.on("other", () => {}); +e.on("other", () => {}); +console.log("added", e.listenerCount("event-type"), e.listenerCount("other")); +process.exitCode = 1; diff --git a/tests/harness/corpus-costs/8640-emitter-arguments-listeners.cjs.json b/tests/harness/corpus-costs/8640-emitter-arguments-listeners.cjs.json new file mode 100644 index 000000000..18b8d7955 --- /dev/null +++ b/tests/harness/corpus-costs/8640-emitter-arguments-listeners.cjs.json @@ -0,0 +1 @@ +{ "plain": 8000, "san": 8100 } diff --git a/tests/harness/corpus-costs/8641-emitter-leak-warning-exit.cjs.json b/tests/harness/corpus-costs/8641-emitter-leak-warning-exit.cjs.json new file mode 100644 index 000000000..d4cd0dbbb --- /dev/null +++ b/tests/harness/corpus-costs/8641-emitter-leak-warning-exit.cjs.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 5000 } From 2473207af24e4f2b0e78d2b470590df8758436f9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 07:18:44 -0500 Subject: [PATCH 069/193] Name the emitter corpus programs instead of numbering them Rename 8640-emitter-arguments-listeners.cjs and 8641-emitter-leak-warning-exit.cjs to emitter-arguments-listeners.cjs and emitter-leak-warning-exit.cjs, with their cost files and ts7 baselines, as AGENTS.md asks for new corpus programs. --- .../tests/corpus/8640-emitter-arguments-listeners.cjs.json | 6 ------ .../tests/corpus/8641-emitter-leak-warning-exit.cjs.json | 6 ------ .../tests/corpus/emitter-arguments-listeners.cjs.json | 6 ++++++ .../tests/corpus/emitter-leak-warning-exit.cjs.json | 6 ++++++ ...uments-listeners.cjs => emitter-arguments-listeners.cjs} | 0 ...-leak-warning-exit.cjs => emitter-leak-warning-exit.cjs} | 0 ...teners.cjs.json => emitter-arguments-listeners.cjs.json} | 0 ...ing-exit.cjs.json => emitter-leak-warning-exit.cjs.json} | 0 8 files changed, 12 insertions(+), 12 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-arguments-listeners.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-leak-warning-exit.cjs.json rename tests/corpus/{8640-emitter-arguments-listeners.cjs => emitter-arguments-listeners.cjs} (100%) rename tests/corpus/{8641-emitter-leak-warning-exit.cjs => emitter-leak-warning-exit.cjs} (100%) rename tests/harness/corpus-costs/{8640-emitter-arguments-listeners.cjs.json => emitter-arguments-listeners.cjs.json} (100%) rename tests/harness/corpus-costs/{8641-emitter-leak-warning-exit.cjs.json => emitter-leak-warning-exit.cjs.json} (100%) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json deleted file mode 100644 index 8a0867122..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8640-emitter-arguments-listeners.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/8640-emitter-arguments-listeners.cjs" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json deleted file mode 100644 index 5c4ad7909..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8641-emitter-leak-warning-exit.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/8641-emitter-leak-warning-exit.cjs" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-arguments-listeners.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-arguments-listeners.cjs.json new file mode 100644 index 000000000..3fdaa5121 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-arguments-listeners.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-arguments-listeners.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-leak-warning-exit.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-leak-warning-exit.cjs.json new file mode 100644 index 000000000..feb0e0908 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-leak-warning-exit.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-leak-warning-exit.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/8640-emitter-arguments-listeners.cjs b/tests/corpus/emitter-arguments-listeners.cjs similarity index 100% rename from tests/corpus/8640-emitter-arguments-listeners.cjs rename to tests/corpus/emitter-arguments-listeners.cjs diff --git a/tests/corpus/8641-emitter-leak-warning-exit.cjs b/tests/corpus/emitter-leak-warning-exit.cjs similarity index 100% rename from tests/corpus/8641-emitter-leak-warning-exit.cjs rename to tests/corpus/emitter-leak-warning-exit.cjs diff --git a/tests/harness/corpus-costs/8640-emitter-arguments-listeners.cjs.json b/tests/harness/corpus-costs/emitter-arguments-listeners.cjs.json similarity index 100% rename from tests/harness/corpus-costs/8640-emitter-arguments-listeners.cjs.json rename to tests/harness/corpus-costs/emitter-arguments-listeners.cjs.json diff --git a/tests/harness/corpus-costs/8641-emitter-leak-warning-exit.cjs.json b/tests/harness/corpus-costs/emitter-leak-warning-exit.cjs.json similarity index 100% rename from tests/harness/corpus-costs/8641-emitter-leak-warning-exit.cjs.json rename to tests/harness/corpus-costs/emitter-leak-warning-exit.cjs.json From f96217e289391d40440d20406292c8e4540646e4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 07:24:45 -0500 Subject: [PATCH 070/193] Count loop-body var initializers as writes, and skip overload signatures - A `var` with an initializer inside a loop body of its own function runs once per iteration, so the binding counts as written, and generic instances share it instead of copying an earlier iteration's value. - Only a function declaration with a body initializes its binding: overload signatures no longer make an overloaded function count as redeclared, so it is still a function that nothing reassigns (for example as a pipeline() function stage). Adds the corpus program pipeline-overloaded-stage.ts, with its corpus cost and order baseline, and a loop-body case to generic-arrow-mutable-captures.ts. --- .../src/frontend/lowering/binding-analysis.ts | 50 ++++++++++++++++--- .../corpus/pipeline-overloaded-stage.ts.json | 6 +++ .../corpus/generic-arrow-mutable-captures.ts | 15 +++++- tests/corpus/pipeline-overloaded-stage.ts | 29 +++++++++++ .../pipeline-overloaded-stage.ts.json | 1 + 5 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pipeline-overloaded-stage.ts.json create mode 100644 tests/corpus/pipeline-overloaded-stage.ts create mode 100644 tests/harness/corpus-costs/pipeline-overloaded-stage.ts.json diff --git a/packages/compiler/src/frontend/lowering/binding-analysis.ts b/packages/compiler/src/frontend/lowering/binding-analysis.ts index a37875f25..09a3bd3c5 100644 --- a/packages/compiler/src/frontend/lowering/binding-analysis.ts +++ b/packages/compiler/src/frontend/lowering/binding-analysis.ts @@ -81,12 +81,14 @@ export function fileWritesBinding( } /** True when `sym`'s own declarations write it after it holds a value: - * a `var` in a for-in or for-of head is written on every iteration, and a - * binding declared more than once (`var x = 1; ... var x = 2;`, a - * parameter or function declaration that a `var` redeclares) is written by - * each initializing declaration that runs. Only value declarations count; - * a redeclaration without an initializer is counted too, which only - * over-approximates. */ + * a `var` in a for-in or for-of head is written on every iteration, a + * `var` with an initializer inside a loop body is written each time the + * body runs, and a binding declared more than once (`var x = 1; ... var x + * = 2;`, a parameter or function declaration that a `var` redeclares) is + * written by each initializing declaration that runs. Only value + * declarations count, and of functions only implementations: overload + * signatures have no body. A redeclaration without an initializer is + * counted too, which only over-approximates. */ function declarationsWriteBinding(sym: ts.Symbol): boolean { let values = 0; let initializing = 0; @@ -108,8 +110,15 @@ function declarationsWriteBinding(sym: ts.Symbol): boolean { ) return true; values++; - if (root.initializer !== undefined) initializing++; - } else if (ts.isParameter(root) || ts.isFunctionDeclaration(d) || ts.isClassDeclaration(d)) { + if (root.initializer !== undefined) { + if (insideLoopBody(root)) return true; + initializing++; + } + } else if ( + ts.isParameter(root) || + (ts.isFunctionDeclaration(d) && d.body !== undefined) || + ts.isClassDeclaration(d) + ) { values++; initializing++; } @@ -117,6 +126,31 @@ function declarationsWriteBinding(sym: ts.Symbol): boolean { return values > 1 && initializing > 0; } +/** Whether a declaration sits in the body of a loop of its own function, + * where it runs once per iteration. */ +function insideLoopBody(node: ts.Node): boolean { + let child = node; + for (let parent = node.parent; parent !== undefined; parent = parent.parent) { + if ( + ts.isFunctionLike(parent) || + ts.isClassStaticBlockDeclaration(parent) || + ts.isSourceFile(parent) + ) + return false; + if ( + (ts.isForStatement(parent) || + ts.isForInStatement(parent) || + ts.isForOfStatement(parent) || + ts.isWhileStatement(parent) || + ts.isDoStatement(parent)) && + parent.statement === child + ) + return true; + child = parent; + } + return false; +} + /** Strips the value-preserving wrappers off an expression: parens, * non-null assertions, `as`/`satisfies`/angle-bracket casts. What * remains is the expression that actually evaluates. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pipeline-overloaded-stage.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pipeline-overloaded-stage.ts.json new file mode 100644 index 000000000..08016e9bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pipeline-overloaded-stage.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/pipeline-overloaded-stage.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-arrow-mutable-captures.ts b/tests/corpus/generic-arrow-mutable-captures.ts index 64d77ee2b..58e104b42 100644 --- a/tests/corpus/generic-arrow-mutable-captures.ts +++ b/tests/corpus/generic-arrow-mutable-captures.ts @@ -98,11 +98,24 @@ function loopVar(): string { return readers.map((reader) => reader()).join(" "); } +// A `var` with an initializer in a loop body is one binding the body writes +// on every iteration: closures returned in earlier iterations read the last +// value. (The binding is declared with the type its hoisted storage has.) +function loopInitializer(): string { + const readers: Array<() => string> = []; + for (let i = 0; i < 3; i++) { + var step: string | undefined = `s${i}`; + const make = (value: T): (() => string) => () => `${step}${String(value)}`; + readers.push(make(i)); + } + return readers.map((reader) => reader()).join(" "); +} + async function main(): Promise { console.log((await makeRunner("p").run(2)).join(" ")); console.log(accumulate(5)); console.log(snapshots()); console.log(depth()); - console.log(redeclared(), loopVar()); + console.log(redeclared(), loopVar(), loopInitializer()); } main(); diff --git a/tests/corpus/pipeline-overloaded-stage.ts b/tests/corpus/pipeline-overloaded-stage.ts new file mode 100644 index 000000000..43502c2cc --- /dev/null +++ b/tests/corpus/pipeline-overloaded-stage.ts @@ -0,0 +1,29 @@ +// pipeline() with a function stage that has overload signatures: the +// signatures have no body, so the stage is still one function declaration +// that nothing reassigns, and the pipeline lowers it like any other stage. +import { pipeline } from "node:stream/promises"; +import { Readable, Writable } from "node:stream"; + +function shout(source: AsyncIterable): AsyncGenerator; +function shout(source: AsyncIterable): AsyncGenerator; +async function* shout(source: AsyncIterable): AsyncGenerator { + for await (const chunk of source) yield `${String(chunk).toUpperCase()}!`; +} + +async function main(): Promise { + const seen: string[] = []; + await pipeline( + Readable.from(["a", "b", "c"]), + shout, + new Writable({ + objectMode: true, + write(chunk: unknown, _encoding, callback) { + seen.push(String(chunk)); + callback(); + }, + }), + ); + console.log(seen.join(" ")); +} + +void main(); diff --git a/tests/harness/corpus-costs/pipeline-overloaded-stage.ts.json b/tests/harness/corpus-costs/pipeline-overloaded-stage.ts.json new file mode 100644 index 000000000..5cce6d4a6 --- /dev/null +++ b/tests/harness/corpus-costs/pipeline-overloaded-stage.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 6000 } From a4c19ae4b4ba4b38749713635b6b498d62759f28 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:04:21 -0700 Subject: [PATCH 071/193] Host the bare compareArray from the upstream assert.js harness harness/assert.js defines `compareArray(a, b)` beside `assert`: a plain function that RETURNS a boolean, whose throwing counterpart is `assert.compareArray` (already hosted). The adapter now injects the bare name into every prepared test and implements the upstream comparison: read both `.length`s, so a nullish operand throws the same TypeError, return false on a length mismatch, otherwise compare elements by SameValue. That also admits the compareArray.js include when the only use is a direct call. A value reference or property read stays excluded, because it would need the upstream function object and its `format` expando. Co-Authored-By: Claude Opus 5.5 --- tests/harness/test262-runner.test.ts | 12 ++++++++++-- tests/harness/test262.test.ts | 20 ++++++++++++++++++++ tests/test262/harness.ts | 22 ++++++++++++++++++++++ tests/test262/support.mjs | 8 +++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/harness/test262-runner.test.ts b/tests/harness/test262-runner.test.ts index 3ddf3ca84..3873d1c34 100644 --- a/tests/harness/test262-runner.test.ts +++ b/tests/harness/test262-runner.test.ts @@ -250,14 +250,22 @@ test("built-in error assertions are admitted without permitting constructor alia expect(exclusion(alias, metadata(alias), "strict")).toBe("harness:assert-surface"); }); -test("the compareArray include only admits the implemented assertion form", () => { +test("the compareArray include admits the assertion and the bare call form only", () => { const accepted = source( "includes: [compareArray.js]", "assert.compareArray([1, NaN], [1, NaN]);", ); expect(exclusion(accepted, metadata(accepted), "strict")).toBeUndefined(); const globalHelper = source("includes: [compareArray.js]", "compareArray([1], [1]);"); - expect(exclusion(globalHelper, metadata(globalHelper), "strict")).toBe( + expect(exclusion(globalHelper, metadata(globalHelper), "strict")).toBeUndefined(); + // assert.js supplies compareArray, so a test needs no include to call it. + const withoutInclude = source("description: intl", "assert(!compareArray([1], [2]));"); + expect(exclusion(withoutInclude, metadata(withoutInclude), "strict")).toBeUndefined(); + const valueReference = source( + "includes: [compareArray.js]", + "const compare = compareArray; assert(compare([1], [1]));", + ); + expect(exclusion(valueReference, metadata(valueReference), "strict")).toBe( "harness:compareArray-surface", ); }); diff --git a/tests/harness/test262.test.ts b/tests/harness/test262.test.ts index 678685103..948c016bb 100644 --- a/tests/harness/test262.test.ts +++ b/tests/harness/test262.test.ts @@ -189,6 +189,26 @@ assert.notSameValue(1, "1"); source: "assert.compareArray([1, 2], [1, 3]);", }, { name: "array signed zero mismatch", status: "fail", source: "assert.compareArray([0], [-0]);" }, + { + name: "bare compareArray returns true for matching contents", + status: "pass", + source: "assert(compareArray([1, NaN, -0], [1, NaN, -0]));", + }, + { + name: "bare compareArray returns false for a length or element mismatch", + status: "pass", + source: "assert(!compareArray([1], [1, 2])); assert(!compareArray([1, 2], [1, 3]));", + }, + { + name: "bare compareArray compares operands without a length as equal", + status: "pass", + source: "assert(compareArray(1, 2)); assert(compareArray({}, {}));", + }, + { + name: "bare compareArray does not throw on a mismatch", + status: "fail", + source: "assert(compareArray([1], [2]));", + }, { name: "assert requires true, not truthiness", status: "fail", source: "assert(1);" }, { name: "exact built-in exception", diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index dce1be99c..8a0f7bd1a 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -60,6 +60,28 @@ export function assert(value: unknown, message?: unknown): void { if (value !== true) throw new Test262Error(text(message, "Expected true")); } +function lengthOf(value: unknown): unknown { + return (value as { length?: unknown }).length; +} + +function elementAt(value: unknown, index: number): unknown { + return (value as Record)[index]; +} + +/** The bare `compareArray(a, b)` the upstream `harness/assert.js` defines + * alongside `assert`. It RETURNS a boolean (the throwing assertion is + * `assert.compareArray`), and it has no shape checks of its own: it reads + * `.length` off both operands, so a null operand throws TypeError there and + * here, and two operands without a length of their own compare equal. */ +export function compareArray(actual: unknown, expected: unknown): boolean { + const length = lengthOf(actual); + if (lengthOf(expected) !== length) return false; + for (let i = 0; typeof length === "number" && i < length; i++) { + if (!same(elementAt(expected, i), elementAt(actual, i))) return false; + } + return true; +} + export namespace assert { export function sameValue(actual: unknown, expected: unknown, message?: unknown): void { if (!same(actual, expected)) throw new Test262Error(text(message, "Expected SameValue")); diff --git a/tests/test262/support.mjs b/tests/test262/support.mjs index 52739f91a..ecb5d45c4 100644 --- a/tests/test262/support.mjs +++ b/tests/test262/support.mjs @@ -169,7 +169,11 @@ export function exclusion(source, meta, variant) { // Supported assertion calls; aliases, mutations, and reflection stay out. } else reason = "harness:assert-surface"; } else if (meta.includes.includes("compareArray.js") && ts.isIdentifier(node) && node.text === "compareArray" && + !(ts.isCallExpression(node.parent) && node.parent.expression === node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === "assert")) { + // assert.js defines the bare compareArray; only its direct call form is + // hosted (a value reference or property read would need the upstream + // function object and its format expando). reason = "harness:compareArray-surface"; } else if (ts.isIdentifier(node) && node.text === "Test262Error") { if (!ts.isNewExpression(node.parent) || node.parent.expression !== node) reason = "harness:Test262Error-surface"; @@ -192,7 +196,9 @@ export function exclusion(source, meta, variant) { } export function prepare(source, asyncTest = false, marker = completion, variant = "strict", includes = []) { - const names = asyncTest ? "assert, Test262Error, $DONE" : "assert, Test262Error"; + const names = asyncTest + ? "assert, compareArray, Test262Error, $DONE" + : "assert, compareArray, Test262Error"; const end = asyncTest ? "" : `;console.log(${JSON.stringify(marker)});\n`; const helpers = (includes.includes("propertyHelper.js") ? `${propertyHelperSource}\n` : "") + From c6db09c8e4f1c3d6bb4e15f372e2758f52af13b8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:07:02 -0700 Subject: [PATCH 072/193] Relax the async-iterator protocol demand in untyped JavaScript TS2504 demands a `[Symbol.asyncIterator]()` method that returns an async iterator. In untyped JavaScript, whether a value is async-iterable is a runtime fact of the value rather than a static one, exactly like the sync demand (2488) relaxed just above. `for await` over a non-async-iterable and `yield*` in an async generator both lower through the runtime protocol openers, which read the method, call it, and throw the same TypeError Node throws when it is missing, uncallable, or returns a non-object. Only diagnostics change, so only variants that previously held a 2504 refusal can move: the relaxed set is consulted only when JavaScript strictness is suppressed, and 2504 is the sole code this adds. The sync sibling 2505 stays enforced on the typed lanes, and TypeScript sources keep 2504 because the relaxation applies to `.js`/`.mjs`/`.cjs`/`.jsx` only. Corpus 8423-async-iterator-protocol-shapes.js pins the seven protocol shapes Node rejects and the two it accepts. Co-Authored-By: Claude Opus 5.5 --- .../frontend/inferred-js-diagnostics.test.ts | 20 ++++ packages/compiler/src/frontend/tsc-codes.ts | 10 ++ .../8423-async-iterator-protocol-shapes.js | 96 +++++++++++++++++++ ...423-async-iterator-protocol-shapes.js.json | 1 + 4 files changed, 127 insertions(+) create mode 100644 tests/corpus/8423-async-iterator-protocol-shapes.js create mode 100644 tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json diff --git a/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts b/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts index 96d285f03..49eab16b5 100644 --- a/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts +++ b/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts @@ -94,3 +94,23 @@ test.for([ invalid, ); }); + +test("JavaScript async generators may delegate over a runtime async-iterator shape", () => { + expect( + diagnostics( + `export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; } +export async function* looped() { for await (const value of { [Symbol.asyncIterator]() { return "text"; } }) console.dir(value); }`, + 'import { delegated, looped } from "./library.js"; delegated(); looped();', + ), + ).toEqual([]); +}); + +test("TypeScript keeps the async-iterator demand", () => { + expect( + diagnostics( + "export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }", + 'import { delegated } from "./library.js"; delegated();', + "ts", + ).some((diagnostic) => diagnostic.message.includes("Symbol.asyncIterator")), + ).toBe(true); +}); diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index a30945be7..1f9ce923e 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -109,6 +109,16 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ 2488, 2721, 2722, + // the async-iterator protocol demand (2504: "must have a + // '[Symbol.asyncIterator]()' method that returns an async iterator"). + // Whether a value is async-iterable is a runtime fact of untyped JS, exactly + // like the sync demand (2488) relaxed above: `for await` over a non-iterable + // value and `yield*` in an async generator both lower through the runtime + // protocol openers, which read the method, call it, and throw TypeError at + // the site when it is missing, uncallable, or returns a non-object — the + // same protocol failure Node reports. 2505 is the sync sibling on the typed + // lowering lanes and stays enforced. + 2504, // an UNUSED @ts-expect-error: the directive annotated an error visible // under the project's OWN tsconfig world — scriptc's world deliberately // differs (adopted options, hidden declaration twins), and an unused diff --git a/tests/corpus/8423-async-iterator-protocol-shapes.js b/tests/corpus/8423-async-iterator-protocol-shapes.js new file mode 100644 index 000000000..f27d3acb6 --- /dev/null +++ b/tests/corpus/8423-async-iterator-protocol-shapes.js @@ -0,0 +1,96 @@ +// Async iteration over values whose [Symbol.asyncIterator] shape only exists +// at run time. JavaScript puts no static demand on the receiver: the protocol +// reads the method, calls it, and throws TypeError when the method is missing, +// is not callable, or returns a non-object. The checker must not refuse the +// source for the shapes inference cannot prove. +function report(label, fn) { + return Promise.resolve() + .then(fn) + .then( + (value) => console.log(label, "ok", value), + (error) => console.log(label, error instanceof TypeError, error.name), + ); +} + +async function* delegate(value) { + yield* value; +} + +async function drain(value) { + const values = []; + for await (const item of value) values.push(item); + return values.join(","); +} + +async function main() { + // A well-formed async iterator written inline still delegates. + await report("inline", () => + drain({ + async *[Symbol.asyncIterator]() { + yield "a"; + yield "b"; + }, + }), + ); + + // The method is a data property, not a function. + await report("method-not-callable", () => drain({ [Symbol.asyncIterator]: 42 })); + + // The method returns a number instead of an iterator object. + await report("result-not-object", () => drain({ [Symbol.asyncIterator]() { return 42; } })); + + // The returned iterator's next is not callable. + await report("next-not-callable", () => + drain({ + [Symbol.asyncIterator]() { + return { next: "nope" }; + }, + }), + ); + + // No async iterator at all and no sync fallback either. + await report("no-protocol", () => drain({ value: 1 })); + + // yield* delegates through the same protocol, so it throws the same way. + await report("yield-star-not-callable", async () => { + let seen = ""; + for await (const value of delegate({ [Symbol.asyncIterator]: true })) seen += value; + return seen; + }); + + await report("yield-star-result-not-object", async () => { + let seen = ""; + for await (const value of delegate({ [Symbol.asyncIterator]() { return "text"; } })) seen += value; + return seen; + }); + + // A non-callable async iterator wins over a working sync iterator: the + // protocol only falls back when the method is undefined. + await report("async-shadows-sync", () => + drain({ + [Symbol.asyncIterator]: 0, + *[Symbol.iterator]() { + yield "never"; + }, + }), + ); + + // A value with only a sync iterable iterates through the sync fallback. + await report("sync-only", () => + drain({ + *[Symbol.iterator]() { + yield "s1"; + yield "s2"; + }, + }), + ); + + // yield* over arrays and strings keeps working. + await report("yield-star-array", async () => { + let seen = ""; + for await (const value of delegate([1, 2])) seen += value; + return seen; + }); +} + +main(); diff --git a/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json b/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json new file mode 100644 index 000000000..35e276558 --- /dev/null +++ b/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json @@ -0,0 +1 @@ +{ "plain": 8004 } From b234e06f2055a7daeeb2b26336c94614652ff006 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:10:30 -0700 Subject: [PATCH 073/193] Enforce the ECMAScript early errors the checker does not model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static semantics reject a program before it runs. Until now the compiler borrowed that rejection from whatever tsc happened to report, and Test262's negative-parse suites show where the borrowing stops: programs the spec makes SyntaxErrors compile and run. This adds the two largest families the grab-bag misses (plan §7.7 D-4, option c), each under its own preflight code, so a checker relaxation can never remove the only rejection a parse-negative program had: SC0005 a statement list declares one lexical name twice (`{ class f {} async function f() {} }`) SC0006 a lexical name is `var`-declared in the same statement list (`switch (1) { case 1: let x; case 2: var x; }`) SC0007 a declaration where the grammar admits only a Statement (`while (false) function f() {}`, `l: class c {}`, `if (x) async function f() {}`) frontend/early-errors.ts is a preflight walk over JavaScript sources, so a rejected program fails where a tsc parse error fails, before any lowering. A case block is one lexical scope across its clauses; a list's VarDeclaredNames reach into nested blocks but never into a function or a class body; and Annex B's allowances are honored exactly: two plain function declarations may repeat in a sloppy block (B.3.3.1), and a plain function may be an if/else body or a labelled statement in sloppy code (B.3.2) — an async function, a generator or a class never gets either allowance, and neither does any function as a loop or 'with' body. Strictness comes from Node's own module classification (isNodeEsmFile7), not from the checker's external-module bit: moduleDetection=force gives every file module scope, CommonJS included, and taking that bit as strictness cost the Annex B exemption on the annexB function-redeclaration tests. Typed sources are untouched — their checker diagnostics (TS2451 and friends) remain the gate, and re-reporting a collision under a second code would only duplicate them. tests/test262/support.mjs counts one of these codes at a location inside the test's own source as the parse-negative match, the same standard the helper already applies to tsc's early errors. The diagnostics fixtures pin both families, including the strict half of SC0007 where Node refuses the two Annex B shapes as well. The pass only reads the AST and only refuses programs, so no typed program's IR changes. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/coverage/surface-manifest.ts | 2 +- packages/compiler/src/diagnostics/codes.ts | 3 + .../compiler/src/diagnostics/diagnostic.ts | 8 +- .../compiler/src/frontend/early-errors.ts | 364 ++++++++++++++++++ packages/compiler/src/frontend/program.ts | 6 + packages/compiler/src/frontend/tsc-codes.ts | 15 + packages/compiler/surface-manifest.json | 2 +- tests/diagnostics/js-block-redeclaration.js | 18 + .../js-single-statement-declaration.js | 10 + .../diagnostics/js-single-statement-strict.js | 8 + .../js-block-redeclaration.js.txt | 41 ++ .../js-single-statement-declaration.js.txt | 48 +++ .../js-single-statement-strict.js.txt | 20 + tests/test262/support.mjs | 21 + 14 files changed, 562 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/src/frontend/early-errors.ts create mode 100644 tests/diagnostics/js-block-redeclaration.js create mode 100644 tests/diagnostics/js-single-statement-declaration.js create mode 100644 tests/diagnostics/js-single-statement-strict.js create mode 100644 tests/harness/__snapshots__/js-block-redeclaration.js.txt create mode 100644 tests/harness/__snapshots__/js-single-statement-declaration.js.txt create mode 100644 tests/harness/__snapshots__/js-single-statement-strict.js.txt diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index b87a8065d..d0cff1eb2 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -98,7 +98,7 @@ function coverageNotes(): string[] { "Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.", `The engine-free fetch projection targets Node ${fetchTarget.node} with bundled Undici ${fetchTarget.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`, "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", + "Process-level diagnostic codes are not surface entries: SC0001-SC0007 are preflight gates (SC0005-SC0007 are the ECMAScript early errors), SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases.", ]; diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index 1e35903cd..bd597cb01 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -19,6 +19,9 @@ export const DIAGNOSTIC_CODES = { SC0002: "project configuration incompatible with scriptc", SC0003: "malformed imported JSON module", SC0004: "TypeScript checker panic", + SC0005: "duplicate lexical declaration in a block or switch statement list", + SC0006: "lexical declaration colliding with a var in the same block", + SC0007: "declaration in a single-statement position", SC1010: "package imports outside the supported set", SC1012: "default exports/imports (residual edges)", SC1013: "namespace imports (residual edges)", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0cabb6ddb..de377441f 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -7,8 +7,12 @@ * Code bands: * SC0xxx preflight gates: TypeScript errors passed through (SC0001), * project-config incompatibilities (SC0002), malformed imported - * JSON modules (SC0003), and upstream tsgo checker panics - * surfaced as source-anchored diagnostics (SC0004) + * JSON modules (SC0003), upstream tsgo checker panics surfaced + * as source-anchored diagnostics (SC0004), and the ECMAScript + * early errors the checker does not model: a duplicated or + * `var`-colliding lexical declaration in a block or switch + * statement list (SC0005/SC0006) and a declaration where the + * grammar admits only a Statement (SC0007) * SC1xxx supported-TypeScript-not-yet: valid TS outside the current subset * SC2xxx scriptc type rules (types we cannot compile yet) * SC3xxx backend/target coverage: the program is valid, but the selected diff --git a/packages/compiler/src/frontend/early-errors.ts b/packages/compiler/src/frontend/early-errors.ts new file mode 100644 index 000000000..37dd71b48 --- /dev/null +++ b/packages/compiler/src/frontend/early-errors.ts @@ -0,0 +1,364 @@ +/* ECMAScript early errors the TypeScript checker does not model. + * + * Static semantics reject a program before it runs, and until this pass + * existed scriptc borrowed that rejection from whatever tsc happened to + * report (`Let x` declarations at an odd position, a redeclared block name + * TypeScript refuses). Test262's negative-parse suites show where the + * borrowing stops: 1,198 programs the spec makes SyntaxErrors compile — the + * constructs below are the ones the corpus covers, in descending variant + * count (plan §7.7 D-4 option (c)). + * + * One code per construct, so C1's checker relaxation stays safe: relaxing a + * tsc code that doubles as an early error removes the ONLY rejection some + * parse-negative test had, which is why these codes live beside + * JS_RELAXED_TSC_CODES in tsc-codes.ts and never depend on it. + * + * Scope: JavaScript sources only. A TypeScript source is type-checked, and + * its checker diagnostics (TS2451 and friends) are the gate; re-reporting + * the same collision under a second code would only duplicate them. + * + * The pass runs at preflight, before any lowering, and reports through + * SC0xxx — the preflight band — so a rejected program fails the same way a + * tsc error does. */ + +import * as ts from "./ts7/adapter.js"; +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import { isJsSourceFileName } from "./tsc-codes.js"; + +/** The early-error codes. SC0005 and SC0006 are the Block/CaseBlock static + * semantics (a statement list may not declare one lexical name twice, and a + * lexical name may not be `var`-declared in the same list); SC0007 is a + * declaration where the grammar admits only a Statement. */ +export const EARLY_ERROR_CODES = ["SC0005", "SC0006", "SC0007"] as const; + +const NO_DUPLICATE_LEXICAL = "SC0005"; +const LEXICAL_VAR_COLLISION = "SC0006"; +const NOT_A_STATEMENT = "SC0007"; + +/** Whether the code around a node is strict — the one input this pass needs + * from outside: Node's own module classification (program.ts's + * isNodeEsmFile7: fixed extensions, package type, then syntax markers). The + * checker's external-module bit cannot answer it, because + * moduleDetection=force gives every file module scope, CommonJS included. */ +export type IsStrictCode = (sf: ts.SourceFile, node: ts.Node) => boolean; + +export function earlyErrorDiagnostics7( + files: readonly ts.SourceFile[], + isModuleCode: (sf: ts.SourceFile) => boolean, +): ScrDiagnostic[] { + const diags: ScrDiagnostic[] = []; + const isStrict: IsStrictCode = (sf, node) => isStrictCode(sf, node, isModuleCode); + for (const sf of files) { + if (!isJsSourceFileName(sf.fileName)) continue; + // Iterative walk: a deeply nested program must not overflow the stack + // before the lowering's own nesting fence runs. + const stack: ts.Node[] = [sf]; + while (stack.length > 0) { + const node = stack.pop()!; + checkStatementListScope(sf, node, diags, isStrict); + checkStatementSlot(node, diags, isStrict); + ts.forEachChild(node, (child) => { + stack.push(child); + return undefined; + }); + } + } + return diags; +} + +function locOf(node: ts.Node): SrcLoc { + const sf = ts.getSourceFileOfNode(node); + return { file: sf.fileName, start: node.getStart(sf), end: node.end }; +} + +function report(diags: ScrDiagnostic[], code: EarlyErrorCode, node: ts.Node, message: string) { + diags.push({ code, message, loc: locOf(node) }); +} + +type EarlyErrorCode = (typeof EARLY_ERROR_CODES)[number]; + +/* ── SC0005 / SC0006: Block and CaseBlock static semantics ─────────────── + * + * Block : { StatementList } + * CaseBlock : { CaseClauses_opt DefaultClause CaseClauses_opt } + * + * * It is a Syntax Error if the LexicallyDeclaredNames of StatementList + * contains any duplicate entries. + * * It is a Syntax Error if any element of the LexicallyDeclaredNames of + * StatementList also occurs in the VarDeclaredNames of StatementList. + * + * A case block is ONE lexical scope across its clauses, and VarDeclaredNames + * reach into nested blocks (a `var` anywhere below the block belongs to it) + * but never into a function or class body. The duplicate rule's one carve-out + * is Annex B's B.3.3.1: two plain function declarations of the same name in + * the same block are legal in non-strict code. */ + +function checkStatementListScope( + sf: ts.SourceFile, + node: ts.Node, + diags: ScrDiagnostic[], + isStrict: IsStrictCode, +): void { + let statements: readonly ts.Statement[] | null = null; + if (ts.isBlock(node)) statements = node.statements.nodes; + else if (ts.isCaseBlock(node)) { + statements = node.clauses.nodes.flatMap((clause) => clause.statements.nodes); + } + if (statements === null) return; + if (statements.length < 2) return; + + const lexical = new Map(); + for (const stmt of statements) { + for (const [name, decl] of lexicalDeclarationsOf(stmt)) { + const list = lexical.get(name); + if (list === undefined) lexical.set(name, [decl]); + else list.push(decl); + } + } + if (lexical.size === 0) return; + + const varNames = new Set(); + for (const stmt of statements) collectVarDeclaredNames(stmt, varNames); + + for (const [name, decls] of lexical) { + if (varNames.has(name)) { + report( + diags, + LEXICAL_VAR_COLLISION, + decls[0]!, + `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`, + ); + continue; + } + if (decls.length < 2) continue; + // Annex B B.3.3.1: duplicate plain function declarations are the web's + // legacy, and only non-strict code keeps them. + if (decls.every((d) => ts.isFunctionDeclaration(d) && !isEnhancedFunction(d))) { + if (!isStrict(sf, decls[0]!)) continue; + } + report( + diags, + NO_DUPLICATE_LEXICAL, + decls[1]!, + `the declaration '${name}' is already declared in this block's statement list`, + ); + } +} + +/** The names a DIRECT statement of a statement list declares lexically + * (let/const/using, class, and every function declaration form), paired with + * the node to point the diagnostic at. */ +function lexicalDeclarationsOf(stmt: ts.Statement): Array<[string, ts.Node]> { + const names: Array<[string, ts.Node]> = []; + if (ts.isVariableStatement(stmt)) { + if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) return names; + for (const decl of stmt.declarationList.declarations.nodes) + collectBindingNames(decl.name, names); + return names; + } + if (ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt)) { + const name = stmt.name; + if (name !== undefined && ts.isIdentifier(name)) names.push([name.text, stmt]); + } + return names; +} + +function collectBindingNames(name: ts.BindingName, into: Array<[string, ts.Node]>): void { + if (ts.isIdentifier(name)) { + into.push([name.text, name]); + return; + } + for (const element of name.elements.nodes) { + if (ts.isOmittedExpression(element)) continue; + if (ts.isBindingElement(element) && element.name !== undefined) + collectBindingNames(element.name, into); + } +} + +/** VarDeclaredNames of a statement: every `var`-declared name below it, + * stopping at function and class boundaries (their declarations are not + * var-scoped here). */ +function collectVarDeclaredNames(root: ts.Node, into: Set): void { + const stack: ts.Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (ts.isFunctionLike(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node)) + continue; + if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0) { + const names: Array<[string, ts.Node]> = []; + for (const decl of node.declarations.nodes) collectBindingNames(decl.name, names); + for (const [name] of names) into.add(name); + } + ts.forEachChild(node, (child) => { + stack.push(child); + return undefined; + }); + } +} + +/** `async function` and `function*` are Annex B's allowance never — only the + * plain form is. */ +function isEnhancedFunction(decl: ts.FunctionDeclaration): boolean { + if (decl.asteriskToken !== undefined) return true; + return (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Async) !== 0; +} + +/* ── SC0007: a declaration where the grammar admits only a Statement ───── + * + * Every `Statement` slot below admits Statement productions only, and only + * Annex B widens three of them to a plain FunctionDeclaration in non-strict + * code: the body of an `if`/`else` and a labelled statement. Async functions, + * generators, classes and lexical declarations are never admitted, and a + * LABELLED function is never admitted as an if/else body (`IsLabelledFunction` + * is an explicit early error there, so `if (x) l: function f(){}` fails even + * in sloppy code). */ +type StatementSlot = "if" | "loop" | "with" | "label"; + +function checkStatementSlot(node: ts.Node, diags: ScrDiagnostic[], isStrict: IsStrictCode): void { + if (ts.isIfStatement(node)) { + checkStatementPosition(node.thenStatement, "if", diags, isStrict); + if (node.elseStatement !== undefined) + checkStatementPosition(node.elseStatement, "if", diags, isStrict); + return; + } + if ( + ts.isWhileStatement(node) || + ts.isDoStatement(node) || + ts.isForStatement(node) || + ts.isForInStatement(node) || + ts.isForOfStatement(node) + ) { + checkStatementPosition(node.statement, "loop", diags, isStrict); + return; + } + if (ts.isWithStatement(node)) { + checkStatementPosition(node.statement, "with", diags, isStrict); + return; + } + if (ts.isLabeledStatement(node)) checkStatementPosition(node.statement, "label", diags, isStrict); +} + +function checkStatementPosition( + stmt: ts.Statement, + slot: StatementSlot, + diags: ScrDiagnostic[], + isStrict: IsStrictCode, +): void { + const sf = ts.getSourceFileOfNode(stmt); + // A labelled function is an early error as an if-clause, loop, or 'with' + // body in BOTH modes (Node agrees); only a plain labelled STATEMENT is + // Annex B's, and that is the `slot === "label"` case, whose terminal + // function the function branches below handle. + const labelTerminal = + slot === "label" || !ts.isLabeledStatement(stmt) ? null : labelledFunctionOf(stmt); + if (labelTerminal !== null) { + report( + diags, + NOT_A_STATEMENT, + labelTerminal, + "a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause", + ); + return; + } + if (ts.isVariableStatement(stmt)) { + if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0) + report( + diags, + NOT_A_STATEMENT, + stmt, + "a lexical declaration cannot appear in a single-statement position", + ); + return; + } + if (ts.isClassDeclaration(stmt)) { + report( + diags, + NOT_A_STATEMENT, + stmt, + "a class declaration cannot appear in a single-statement position", + ); + return; + } + if (!ts.isFunctionDeclaration(stmt)) return; + if (isEnhancedFunction(stmt)) { + report( + diags, + NOT_A_STATEMENT, + stmt, + "an async function or generator declaration can only appear at the top level or inside a block", + ); + return; + } + // A plain function declaration: Annex B admits it as an if/else body and as + // a labelled statement, in non-strict code only, and never as a loop's or a + // 'with' statement's body. + if (slot === "loop" || slot === "with") { + report( + diags, + NOT_A_STATEMENT, + stmt, + "a function declaration can only appear at the top level, inside a block, or as the body of an if statement", + ); + return; + } + if (isStrict(sf, stmt)) + report( + diags, + NOT_A_STATEMENT, + stmt, + "strict mode code may not declare a function in a single-statement position", + ); +} + +/** The function declaration at the end of a `label:` chain, when `stmt` is a + * labelled statement around one. */ +function labelledFunctionOf(stmt: ts.Statement): ts.FunctionDeclaration | null { + let cur: ts.Statement = stmt; + while (ts.isLabeledStatement(cur)) cur = cur.statement; + return ts.isFunctionDeclaration(cur) ? cur : null; +} + +/* ── strictness of the code around a node ─────────────────────────────── + * + * A directive prologue's "use strict" applies to the function (or script) it + * opens, arrows cannot carry one, and a class body is strict by + * construction. Module sources are strict whether they say so or not, and + * Node's classification — not the checker's forced external-module scope — + * decides which sources are modules. */ +function isStrictCode( + sf: ts.SourceFile, + node: ts.Node, + isModuleCode: (sf: ts.SourceFile) => boolean, +): boolean { + if (isModuleCode(sf)) return true; + for (let n: ts.Node | undefined = node; n !== undefined; n = n.parent) { + if (ts.isSourceFile(n)) return hasUseStrictPrologue(n.statements.nodes); + if ( + ts.isClassStaticBlockDeclaration(n) || + ts.isMethodDeclaration(n) || + ts.isConstructorDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isPropertyDeclaration(n) + ) + return true; + if (ts.isArrowFunction(n)) continue; + if (ts.isFunctionLike(n)) { + const body = n.body; + if (body !== undefined && ts.isBlock(body) && hasUseStrictPrologue(body.statements.nodes)) + return true; + } + } + return false; +} + +function hasUseStrictPrologue(statements: readonly ts.Statement[]): boolean { + for (const stmt of statements) { + if (!ts.isExpressionStatement(stmt)) return false; + const expr = stmt.expression; + if (!ts.isStringLiteral(expr)) return false; + if (expr.text === "use strict") return true; + } + return false; +} diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 348ca658e..0d3269b3c 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -136,6 +136,7 @@ import { JS_RELAXED_TSC_CODES, } from "./tsc-codes.js"; import { trackedFileExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; +import { earlyErrorDiagnostics7 } from "./early-errors.js"; import { forkTargetPaths } from "./fork-target.js"; import { workerTargetPaths } from "./worker-target.js"; import { @@ -6673,6 +6674,11 @@ function preflight7(load: LoadResult): { esmNamedImportLinkCheck(program, entry, order, diags), ); + // ECMAScript early errors the TypeScript checker does not model (plan + // §7.7 D-4): a JavaScript source the spec makes a SyntaxError fails here, + // before any lowering, exactly where a tsc parse error fails. + diags.push(...earlyErrorDiagnostics7(order, (sf) => isNodeEsmFile7(sf, program))); + return { diags, moduleOrder: order, diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index 1f9ce923e..79a95c2b4 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -36,6 +36,21 @@ export const ADOPTED_OPTIONS = [ "reactNamespace", ] as const; +/* ── The early-error boundary (read this before relaxing a code) ──────── + * + * Some of the codes below do double duty: TypeScript reports them for a + * JavaScript construct that ECMAScript ALSO makes a SyntaxError (a + * redeclared block binding, a declaration in a statement-only position). + * Relaxing such a code removes that construct's only rejection, so a + * parse-negative program would start compiling and running. + * + * frontend/early-errors.ts owns those rejections now, under their own + * preflight codes — SC0005 (duplicate lexical declaration in a block or + * switch statement list), SC0006 (lexical declaration colliding with a + * `var` in the same block), SC0007 (declaration in a single-statement + * position). A relaxation that needs one of those rules to keep firing must + * land with the matching rule in that pass, not rely on this table. */ + export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // implicit any (params, variables, elements, this, returns, rest, // construct signatures, index reads over shapes with no index diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..34914a034 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -9,7 +9,7 @@ "Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.", "The engine-free fetch projection targets Node 24.22.0 with bundled Undici 7.29.1. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.", "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", + "Process-level diagnostic codes are not surface entries: SC0001-SC0007 are preflight gates (SC0005-SC0007 are the ECMAScript early errors), SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases." ], diff --git a/tests/diagnostics/js-block-redeclaration.js b/tests/diagnostics/js-block-redeclaration.js new file mode 100644 index 000000000..c6af1944b --- /dev/null +++ b/tests/diagnostics/js-block-redeclaration.js @@ -0,0 +1,18 @@ +// ECMAScript early errors the TypeScript checker does not model: a statement +// list may not declare one lexical name twice, and a lexical name may not be +// `var`-declared in the same list (Block : { StatementList }, and a switch's +// case block is one such list). Both are SC0005/SC0006, reported at +// preflight — the constructs come from Test262's negative-parse suites. +{ + class collide {} + async function collide() {} +} + +switch (1) { + case 1: + let scoped = 1; + break; + case 2: + var scoped = 2; + break; +} diff --git a/tests/diagnostics/js-single-statement-declaration.js b/tests/diagnostics/js-single-statement-declaration.js new file mode 100644 index 000000000..21a07b54a --- /dev/null +++ b/tests/diagnostics/js-single-statement-declaration.js @@ -0,0 +1,10 @@ +// ECMAScript early errors the TypeScript checker does not model: the body of +// a loop, a `with` statement, or an if clause admits only a Statement, and +// Annex B's function-declaration allowance never covers an async function, a +// generator, a class, or a lexical declaration. SC0007, reported at +// preflight — the shapes come from Test262's negative-parse suites. +while (false) function inLoop() {} +l: class inLabel {} +if (true) async function inIf() {} +if (false) il: function labelledInIf() {} +do function* inDoWhile() {} while (false); diff --git a/tests/diagnostics/js-single-statement-strict.js b/tests/diagnostics/js-single-statement-strict.js new file mode 100644 index 000000000..d13cddbcf --- /dev/null +++ b/tests/diagnostics/js-single-statement-strict.js @@ -0,0 +1,8 @@ +// The strict half of SC0007: Annex B's allowance for a plain function +// declaration in an if/else body or a labelled statement is non-strict code +// only, so a "use strict" prologue takes it away — Node agrees on both +// shapes. The sloppy fixture beside this one pins the refusals that hold in +// both modes. +"use strict"; +if (true) function inIf() {} +l: function inLabel() {} diff --git a/tests/harness/__snapshots__/js-block-redeclaration.js.txt b/tests/harness/__snapshots__/js-block-redeclaration.js.txt new file mode 100644 index 000000000..4e73a842c --- /dev/null +++ b/tests/harness/__snapshots__/js-block-redeclaration.js.txt @@ -0,0 +1,41 @@ +js-block-redeclaration.js:7:9 - error SC0001: Class declaration cannot implement overload list for 'collide'. + + 6 | { + 7 | class collide {} + | ^~~~~~~ + 8 | async function collide() {} + +js-block-redeclaration.js:8:3 - error SC0005: the declaration 'collide' is already declared in this block's statement list + + 7 | class collide {} + 8 | async function collide() {} + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ + 9 | } + +js-block-redeclaration.js:8:18 - error SC0001: Function with bodies can only merge with classes that are ambient. + + 7 | class collide {} + 8 | async function collide() {} + | ^~~~~~~ + 9 | } + +js-block-redeclaration.js:13:9 - error SC0006: the lexical declaration 'scoped' collides with a 'var' declaration of the same name in this block + + 12 | case 1: + 13 | let scoped = 1; + | ^~~~~~ + 14 | break; + +js-block-redeclaration.js:15:8 - error SC0001: Type '2' is not comparable to type '1'. + + 14 | break; + 15 | case 2: + | ^ + 16 | var scoped = 2; + +js-block-redeclaration.js:16:9 - error SC0001: Cannot initialize outer scoped variable 'scoped' in the same scope as block scoped declaration 'scoped'. + + 15 | case 2: + 16 | var scoped = 2; + | ^~~~~~ + 17 | break; \ No newline at end of file diff --git a/tests/harness/__snapshots__/js-single-statement-declaration.js.txt b/tests/harness/__snapshots__/js-single-statement-declaration.js.txt new file mode 100644 index 000000000..892b49ce3 --- /dev/null +++ b/tests/harness/__snapshots__/js-single-statement-declaration.js.txt @@ -0,0 +1,48 @@ +js-single-statement-declaration.js:6:15 - error SC0007: a function declaration can only appear at the top level, inside a block, or as the body of an if statement + + 5 | // preflight — the shapes come from Test262's negative-parse suites. + 6 | while (false) function inLoop() {} + | ^~~~~~~~~~~~~~~~~~~~ + 7 | l: class inLabel {} + +js-single-statement-declaration.js:7:1 - error SC0001: A label is not allowed here. + + 6 | while (false) function inLoop() {} + 7 | l: class inLabel {} + | ^ + 8 | if (true) async function inIf() {} + +js-single-statement-declaration.js:7:4 - error SC0007: a class declaration cannot appear in a single-statement position + + 6 | while (false) function inLoop() {} + 7 | l: class inLabel {} + | ^~~~~~~~~~~~~~~~ + 8 | if (true) async function inIf() {} + +js-single-statement-declaration.js:8:11 - error SC0007: an async function or generator declaration can only appear at the top level or inside a block + + 7 | l: class inLabel {} + 8 | if (true) async function inIf() {} + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 9 | if (false) il: function labelledInIf() {} + +js-single-statement-declaration.js:9:12 - error SC0001: A label is not allowed here. + + 8 | if (true) async function inIf() {} + 9 | if (false) il: function labelledInIf() {} + | ^~ + 10 | do function* inDoWhile() {} while (false); + +js-single-statement-declaration.js:9:16 - error SC0007: a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause + + 8 | if (true) async function inIf() {} + 9 | if (false) il: function labelledInIf() {} + | ^~~~~~~~~~~~~~~~~~~~~~~~~~ + 10 | do function* inDoWhile() {} while (false); + +js-single-statement-declaration.js:10:4 - error SC0007: an async function or generator declaration can only appear at the top level or inside a block + + 9 | if (false) il: function labelledInIf() {} + 10 | do function* inDoWhile() {} while (false); + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 11 | \ No newline at end of file diff --git a/tests/harness/__snapshots__/js-single-statement-strict.js.txt b/tests/harness/__snapshots__/js-single-statement-strict.js.txt new file mode 100644 index 000000000..0fdfcfe6d --- /dev/null +++ b/tests/harness/__snapshots__/js-single-statement-strict.js.txt @@ -0,0 +1,20 @@ +js-single-statement-strict.js:7:11 - error SC0007: strict mode code may not declare a function in a single-statement position + + 6 | "use strict"; + 7 | if (true) function inIf() {} + | ^~~~~~~~~~~~~~~~~~ + 8 | l: function inLabel() {} + +js-single-statement-strict.js:8:1 - error SC0001: A label is not allowed here. + + 7 | if (true) function inIf() {} + 8 | l: function inLabel() {} + | ^ + 9 | + +js-single-statement-strict.js:8:4 - error SC0007: strict mode code may not declare a function in a single-statement position + + 7 | if (true) function inIf() {} + 8 | l: function inLabel() {} + | ^~~~~~~~~~~~~~~~~~~~~ + 9 | \ No newline at end of file diff --git a/tests/test262/support.mjs b/tests/test262/support.mjs index ecb5d45c4..46c601b7c 100644 --- a/tests/test262/support.mjs +++ b/tests/test262/support.mjs @@ -261,8 +261,29 @@ function earlyErrorDiagnostics(source, variant) { !(variant === "sloppy" && (diagnostic.code === 1214 || diagnostic.code === 1215))); } +// The compiler's OWN early errors (frontend/early-errors.ts): static +// semantics tsc does not model, reported at preflight under their own codes. +// A negative-parse test only asks for SOME SyntaxError, so one of these +// inside the test's source is a match — the same standard as the tsc +// diagnostics above, minus tsc's ability to tell us where it points. +const ownEarlyErrorCodes = new Set(["SC0005", "SC0006", "SC0007"]); + export function matchesParseNegative(outcome, source, variant = "strict") { if (outcome.status !== "compile-refusal") return false; + { + const prepared = prepare(source, false, completion, variant); + const start = prepared.indexOf(source); + const end = start + source.length; + const suffix = variant === "sloppy" ? "/main.cjs" : "/main.js"; + const own = outcome.diagnostics?.some( + (reported) => + ownEarlyErrorCodes.has(reported.code) && + reported.loc?.file?.endsWith(suffix) && + reported.loc.start >= start && + reported.loc.start < end, + ); + if (own) return true; + } const matches = (parsed) => outcome.diagnostics?.some((reported) => reported.code === "SC0001" && reported.loc?.file?.endsWith(variant === "sloppy" ? "/main.cjs" : "/main.js") && From ff93a5a4de2e123f72a7e4cf8c48daaacadb6c96 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:03:49 -0700 Subject: [PATCH 074/193] Refuse sloppy implicit-global writes by name, and relax undeclared JS names Node resolves a name nothing declares at run time. In sloppy mode a WRITE creates a property on the process-wide global object, which a native binary has no object for, so those programs belong on the Node island route (plan D-2/D-5). Until that routing exists, an undeclared write in a `.js` source is refused at build time under a dedicated code, SC1091, whose text names the island route instead of failing with a generic no-lowering message. Reaching that refusal at all means the checker has to stop treating an undeclared name in a JavaScript source as a compile error, so 2304, its did-you-mean spelling 2552, and shorthand position's 18004 join the JS relaxation set. The relaxation stays `isJsSourceFileName`-gated: TypeScript input keeps the compile error, where an undeclared name is a bug rather than a runtime path. The refusal is a preflight walk over every JavaScript file, so it reports once per write, before any code is emitted, at the span the checker used. `isJsWriteTarget7` is shared with the lowerer's unresolved-reference fence, which supplies the same refusal for a write that reaches lowering by a path preflight does not model. An empty identifier is skipped: it is a parse recovery artifact, never a name a program writes. The JS-read half of D-2 (the name lowers to Node's catchable `ReferenceError`) is the next commit; this one only changes what the build says about a write. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/diagnostics/codes.ts | 1 + .../compiler/src/diagnostics/diagnostic.ts | 5 + .../compiler/src/frontend/lowering/lowerer.ts | 13 +++ packages/compiler/src/frontend/program.ts | 106 ++++++++++++++++++ packages/compiler/src/frontend/tsc-codes.ts | 14 +++ packages/compiler/surface-manifest.json | 8 ++ .../sloppy-implicit-global-write.js | 6 + .../sloppy-implicit-global-write.js.txt | 8 ++ 8 files changed, 161 insertions(+) create mode 100644 tests/diagnostics/sloppy-implicit-global-write.js create mode 100644 tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index bd597cb01..3ec5e6c87 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -43,6 +43,7 @@ export const DIAGNOSTIC_CODES = { SC1071: "generators", SC1080: "'this' outside a class method", SC1090: "this syntax (generic construct fence)", + SC1091: "write to an undeclared name in a JavaScript source", SC1100: "operations on 'unknown' values", SC1101: "converting typed values to 'unknown'", SC1110: "comptime evaluation failure", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index de377441f..6b5973418 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -674,6 +674,11 @@ export const UNSUPPORTED: Record = { // remains is `this` outside any method (top level, plain functions). SC1080: { feature: "'this' outside a class method", milestone: "later" }, SC1090: { feature: "this syntax", milestone: "later" }, // generic fallback; message names the construct + SC1091: { + feature: "writing an undeclared name in a JavaScript source", + milestone: "later", + hint: "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)", + }, SC1100: { feature: "operations on 'unknown' values", milestone: "later", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..c1759e8a5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -357,6 +357,7 @@ import { overridesDtsPath, npmStaticDepSf7, requireSpecOf, + isJsWriteTarget7, resolveImport, workspacePackageOfPath, type CycleEdge, @@ -15512,6 +15513,18 @@ export class Lowerer { node: ts.Node, fallback: string, ): never { + // A `.js` WRITE to a name nothing declares. Sloppy mode resolves it by + // creating a property on the global object — process-wide state a native + // binary has no object for, so those programs run in a Node island + // (plan D-5). Until the write is routed there it is refused by name + // rather than by the generic no-lowering text. + if (symbol === null && isJsWriteTarget7(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) { + this.unsupported( + "SC1091", + node, + `writing the undeclared name '${name}' in a JavaScript source`, + ); + } if (this.isBlockedBinding(symbol)) { this.pushDiag(blockedBindingUseDiag(name, locOf(node))); throw new PoisonError(); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 0d3269b3c..2bd726de4 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -2759,6 +2759,107 @@ function isAssignmentTarget7(node: ts.Node): boolean { ); } +/** True when the identifier is a reference position that WRITES: an + * assignment (or compound-assignment) target, a `++`/`--` operand, a + * for-in/for-of head, or an element of a destructuring pattern — through + * parentheses and pattern containers, exactly the shapes a sloppy-mode + * implicit global can be introduced by. */ +export function isJsWriteTarget7(node: ts.Node): boolean { + let cur: ts.Node = node; + for (;;) { + const parent: ts.Node | undefined = cur.parent; + if (parent === undefined) return false; + if (ts.isParenthesizedExpression(parent)) { + cur = parent; + continue; + } + if (ts.isBinaryExpression(parent)) + return ( + parent.left === cur && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ); + if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) + return ( + parent.operand === cur && + (parent.operator === ts.SyntaxKind.PlusPlusToken || + parent.operator === ts.SyntaxKind.MinusMinusToken) + ); + if (ts.isForInStatement(parent) || ts.isForOfStatement(parent)) + return parent.initializer === cur; + if (ts.isArrayLiteralExpression(parent) || ts.isObjectLiteralExpression(parent)) { + cur = parent; + continue; + } + if ( + (ts.isShorthandPropertyAssignment(parent) && parent.name === cur) || + (ts.isPropertyAssignment(parent) && parent.initializer === cur) || + ((ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) && parent.expression === cur) + ) { + cur = parent; + continue; + } + return false; + } +} + +/** Nothing binds `node`: the tsc had no symbol, and a shorthand pattern's + * name has no VALUE binding either (its property symbol is the target, not a + * declaration of the name being written). */ +function undeclaredJsName7( + checker: ReturnType, + node: ts.Identifier, +): boolean { + const parent = node.parent; + if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) + return checker.getShorthandAssignmentValueSymbol(parent) === undefined; + return checker.getSymbolAtLocation(node) === undefined; +} + +/** A `.js` WRITE to a name nothing declares (plan D-2/D-5). Sloppy mode + * resolves such a write by creating a property on Node's global object — + * process-wide state a native binary has no object for — so the program is + * refused at build time, under SC1091, whose text names the Node-island + * route those semantics need. A READ of the same name is admitted instead: + * it throws Node's own catchable `ReferenceError` at the access + * (lowering/expressions/identifiers.ts). TypeScript input never reaches + * either path — an undeclared name is a compile error there. */ +function jsUndeclaredWriteDiags( + program: ts.Program, + files: readonly ts.SourceFile[], +): ScrDiagnostic[] { + const diags: ScrDiagnostic[] = []; + const checker = program.getTypeChecker(); + for (const sf of files) { + if (!isJsSourceFileName(sf.fileName)) continue; + const stack: ts.Node[] = [sf]; + while (stack.length > 0) { + const node = stack.pop()!; + // An empty name is a parse-recovery artifact (`@import x = require(…)` + // in a JSDoc prologue leaves one behind), never a reference a program + // writes; the syntax error that created it is already reported. + if ( + ts.isIdentifier(node) && + node.text !== "" && + isJsWriteTarget7(node) && + undeclaredJsName7(checker, node) + ) + diags.push( + unsupportedDiag( + "SC1091", + { file: sf.fileName, start: node.getStart(sf), end: node.end }, + `writing the undeclared name '${node.text}' in a JavaScript source`, + ), + ); + ts.forEachChild(node, (child) => { + stack.push(child); + return undefined; + }); + } + } + return diags; +} + /** True when `node` is inside the try block of a try statement in the same * function (a catch clause or finally block does not count). A class field * initializer runs when the class is instantiated, so it is a boundary too. */ @@ -6583,6 +6684,11 @@ function preflight7(load: LoadResult): { }; visit(entry); + // ECMAScript's sloppy-mode WRITE to an undeclared name, which the JS + // relaxation above admits for READS (plan D-2): refused by name, with the + // island route in the text (SC1091). + for (const d of jsUndeclaredWriteDiags(program, order)) diags.push(d); + // The startup crash Node's RESOLUTION phase reports: modules resolve // their request specifiers in source order, children linking depth-first // as each resolves (ModuleJob.syncLink) — a preorder walk over the diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index 79a95c2b4..9bf16dc80 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -175,6 +175,20 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // ride the checked-dynamic tree; unsupported operations report a compile-time diagnostic). 18046, 2571, + // UNDECLARED NAMES in a JavaScript source: Node resolves them at run time, + // never at compile time — a READ throws the catchable + // `ReferenceError: is not defined` at the access (`typeof` of one + // answers "undefined"), and a sloppy-mode WRITE creates a global. The + // lowerer turns the read into `scr_undef_global_read` (exact Node text and + // behavior) and refuses the write with SC1091, which names the Node-island + // route the sloppy semantics need (the plan's D-2/D-5). TypeScript input + // keeps the compile error: an undeclared name there is a bug, not a + // runtime path. 2552 is 2304's did-you-mean spelling, and 18004 is the + // same unresolved name in SHORTHAND position (`{ x }`), which the checker + // reports against the property instead of the reference. + 2304, + 2552, + 18004, ]); /* Arithmetic on an `any` operand (TS2362/2363/2365 fire when the other diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 34914a034..793625daa 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -170,6 +170,14 @@ "status": "unsupported", "code": "SC1090" }, + { + "id": "diagnostic.sc1091", + "kind": "diagnostic-fence", + "name": "writing an undeclared name in a JavaScript source", + "status": "unsupported", + "code": "SC1091", + "note": "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)" + }, { "id": "diagnostic.sc1100", "kind": "diagnostic-fence", diff --git a/tests/diagnostics/sloppy-implicit-global-write.js b/tests/diagnostics/sloppy-implicit-global-write.js new file mode 100644 index 000000000..895e27e93 --- /dev/null +++ b/tests/diagnostics/sloppy-implicit-global-write.js @@ -0,0 +1,6 @@ +// A sloppy-mode implicit-global WRITE in a JavaScript source. Node resolves +// the write by creating a property on the global object — process-wide state +// a native binary has none of — so the write is refused by name (SC1091) and +// names the Node-island route the sloppy semantics need (plan D-2/D-5). +implicitGlobal = 42; +console.log(implicitGlobal); diff --git a/tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt b/tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt new file mode 100644 index 000000000..31d294056 --- /dev/null +++ b/tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt @@ -0,0 +1,8 @@ +sloppy-implicit-global-write.js:5:1 - error SC1091: writing the undeclared name 'implicitGlobal' in a JavaScript source is not supported yet + + 4 | // names the Node-island route the sloppy semantics need (plan D-2/D-5). + 5 | implicitGlobal = 42; + | ^~~~~~~~~~~~~~ + 6 | console.log(implicitGlobal); + + hint: a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there) \ No newline at end of file From 48d524a3216d650de55e925c5e9327c69dee1eab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:04:04 -0700 Subject: [PATCH 075/193] Lower a JavaScript read of an undeclared name to Node's ReferenceError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-2: Node resolves a name nothing declares at run time, and the read always throws the catchable `ReferenceError: x is not defined` at the access. A `.js` read of such a name now compiles to exactly that throw instead of refusing the program, with Node's message and Node's catchability. The read's value is checked-dynamic (the checker's error-any after the relaxation in the previous commit, narrowed by the use site where one provides context), and every path through it unwinds before a use observes the value, so the dynamic slot never carries a wrong value. Shorthand position (`{ x }`) needs its own declaration test: there the checker answers the PROPERTY symbol, so the name is bound when `getShorthandAssignmentValueSymbol` has no value for it. `typeof` is the one operator that never touches its operand value, and Node answers "undefined" for an undeclared name, so `lowerDynTypeofTest` folds the test to a literal before the read is ever lowered — `typeof x === "undefined"` stays true and `typeof x` alone prints "undefined". No name that Node serves from the global object reaches this branch: the compiler models those as declared (that is how the corpus reads `process`, `console` and friends), so this is the name Node itself would report missing. The corpus program pins every shape: bare read, call, member read, arithmetic, comparison, shorthand, destructuring default, typeof test, a guarded read that keeps running after the throw, and a declared control. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/identifiers.ts | 21 ++++++++++ .../frontend/lowering/expressions/typeof.ts | 18 +++++++++ tests/corpus/8423-js-undeclared-read.js | 40 +++++++++++++++++++ .../8423-js-undeclared-read.js.json | 1 + 4 files changed, 80 insertions(+) create mode 100644 tests/corpus/8423-js-undeclared-read.js create mode 100644 tests/harness/corpus-costs/8423-js-undeclared-read.js.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..807f47413 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -1031,6 +1031,27 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: ); } } + // A `.js` READ of a name NOTHING declares. Node resolves such a name at + // run time: the access throws the catchable `ReferenceError: is not + // defined` (a `typeof` test lowers earlier, in expressions/typeof.ts, and + // answers "undefined" — exactly Node's rule), so the read compiles to that + // throw and never yields a value. The value is checked-dynamic (the + // checker's error-any), which every use site carries or fences — and the + // read always unwinds before any of them observes it. A WRITE to the same + // name is not a read: it reaches rejectUnresolved and its SC1091 story. + // Typed sources keep the compile error (preflight's JS relaxation is + // `isJsSourceFileName`-gated, so no TS file reaches this branch). + // In shorthand position (`{ x }`) `resolveValueSymbol` answers the + // PROPERTY symbol, so the declaration test there is the checker's + // shorthand-VALUE symbol. + const parent = expr.parent; + const undeclared = + parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === expr + ? lowerer.checker.getShorthandAssignmentValueSymbol(parent) === undefined + : lowerer.resolveValueSymbol(expr) === null; + if (undeclared && isJsSourceFile(ts.getSourceFileOfNode(expr))) { + return nsUndefRead(lowerer, expr.text, expr, contextualUndefReadType(lowerer, expr) ?? DYN); + } // Preflight guarantees no unresolved identifiers; anything else here // is a blocked declaration's binding (the SC2004 cascade) or a // binding form we don't model yet. diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts index 5f1e4a830..6a545ffcb 100644 --- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -493,6 +493,24 @@ export function lowerDynTypeofTest( // preserving both operands' evaluation order without a narrowing test. if (!ts.isStringLiteral(b)) continue; const declared = lowerer.mapTypeOf(lowerer.typeOf(a.expression)); + // `typeof undeclared === "..."` in a JavaScript source: nothing declares + // the name, so Node answers "undefined" without ever reading it — the + // test is that constant's comparison, never a read (a read throws). + { + let operand = a.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if ( + ts.isIdentifier(operand) && + isJsSourceFile(ts.getSourceFileOfNode(operand)) && + lowerer.resolveValueSymbol(operand) === null + ) + return { + kind: "boolLit", + value: (b.text === "undefined") !== negated, + type: BOOL, + loc, + }; + } let value: IrExpr; if (declared?.kind === "dyn") { value = lowerer.lowerExpr(a.expression); diff --git a/tests/corpus/8423-js-undeclared-read.js b/tests/corpus/8423-js-undeclared-read.js new file mode 100644 index 000000000..cd9caef55 --- /dev/null +++ b/tests/corpus/8423-js-undeclared-read.js @@ -0,0 +1,40 @@ +// A JavaScript READ of a name nothing declares resolves at run time, exactly +// as Node does: the access throws a catchable ReferenceError whose message +// names the identifier, `typeof` answers "undefined" without throwing, and a +// call through the same name throws at the callee read (plan D-2 / C5). The +// undeclared name is lowered to the runtime's global-read helper, which +// throws Node's " is not defined". +'use strict'; + +const describe = (label, fn) => { + try { + console.log(`${label}: ${String(fn())}`); + } catch (e) { + console.log(`${label}: threw ${e.name}: ${e.message}`); + } +}; + +console.log("typeof:", typeof neverDeclared); + +describe("bare read", () => neverDeclared); +describe("call", () => neverDeclared(1, 2)); +describe("member read", () => neverDeclared.length); +describe("arithmetic", () => neverDeclared + 1); +describe("comparison", () => neverDeclared === undefined); +describe("shorthand property", () => { + const wrapper = { neverDeclared }; + return String(wrapper.neverDeclared); +}); +describe("destructuring default", () => { + const pick = ({ value = unresolvableReference } = {}) => value; + return pick({}); +}); +describe("typeof test", () => typeof neverDeclared === "undefined"); + +// `typeof` never throws, so a guarded read is the one safe shape. +if (typeof maybeConfigured !== "undefined") console.log("configured:", maybeConfigured); +else console.log("configured: unset"); + +// A declared binding keeps the ordinary lowered path. +let declared = 3; +describe("declared", () => declared * 2); diff --git a/tests/harness/corpus-costs/8423-js-undeclared-read.js.json b/tests/harness/corpus-costs/8423-js-undeclared-read.js.json new file mode 100644 index 000000000..23aca49c0 --- /dev/null +++ b/tests/harness/corpus-costs/8423-js-undeclared-read.js.json @@ -0,0 +1 @@ +{ "plain": 3300 } From b1b7aff25cf69f46a0435501d0d69c0992aea04b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 16:41:21 -0700 Subject: [PATCH 076/193] Renumber the new corpus programs to 8514 and 8515 Group C adds two corpus programs whose 8423 prefix another group also proposes; main's highest corpus number is 8513, so take the next two free numbers and move the matching per-program cost files with them. Co-Authored-By: Claude Opus 5.5 --- ...-protocol-shapes.js => 8514-async-iterator-protocol-shapes.js} | 0 .../{8423-js-undeclared-read.js => 8515-js-undeclared-read.js} | 0 ...shapes.js.json => 8514-async-iterator-protocol-shapes.js.json} | 0 ...js-undeclared-read.js.json => 8515-js-undeclared-read.js.json} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/corpus/{8423-async-iterator-protocol-shapes.js => 8514-async-iterator-protocol-shapes.js} (100%) rename tests/corpus/{8423-js-undeclared-read.js => 8515-js-undeclared-read.js} (100%) rename tests/harness/corpus-costs/{8423-async-iterator-protocol-shapes.js.json => 8514-async-iterator-protocol-shapes.js.json} (100%) rename tests/harness/corpus-costs/{8423-js-undeclared-read.js.json => 8515-js-undeclared-read.js.json} (100%) diff --git a/tests/corpus/8423-async-iterator-protocol-shapes.js b/tests/corpus/8514-async-iterator-protocol-shapes.js similarity index 100% rename from tests/corpus/8423-async-iterator-protocol-shapes.js rename to tests/corpus/8514-async-iterator-protocol-shapes.js diff --git a/tests/corpus/8423-js-undeclared-read.js b/tests/corpus/8515-js-undeclared-read.js similarity index 100% rename from tests/corpus/8423-js-undeclared-read.js rename to tests/corpus/8515-js-undeclared-read.js diff --git a/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json b/tests/harness/corpus-costs/8514-async-iterator-protocol-shapes.js.json similarity index 100% rename from tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json rename to tests/harness/corpus-costs/8514-async-iterator-protocol-shapes.js.json diff --git a/tests/harness/corpus-costs/8423-js-undeclared-read.js.json b/tests/harness/corpus-costs/8515-js-undeclared-read.js.json similarity index 100% rename from tests/harness/corpus-costs/8423-js-undeclared-read.js.json rename to tests/harness/corpus-costs/8515-js-undeclared-read.js.json From f76a8202dd82be7d0ad6faa4bc44c6f3d4296858 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 17:58:14 -0700 Subject: [PATCH 077/193] Record order-parity baselines for the group C fixtures Add the TS7 preflight/order baselines for the new diagnostics and corpus fixtures so the completeness check and the parity batches cover them. Co-Authored-By: Claude Opus 5.5 --- ...514-async-iterator-protocol-shapes.js.json | 6 ++ .../corpus/8515-js-undeclared-read.js.json | 6 ++ .../js-block-redeclaration.js.json | 61 ++++++++++++++++ .../js-single-statement-declaration.js.json | 70 +++++++++++++++++++ .../js-single-statement-strict.js.json | 34 +++++++++ .../sloppy-implicit-global-write.js.json | 18 +++++ 6 files changed, 195 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json new file mode 100644 index 000000000..736bdb4c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8514-async-iterator-protocol-shapes.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json new file mode 100644 index 000000000..a8baf582c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8515-js-undeclared-read.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json new file mode 100644 index 000000000..616d4614d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json @@ -0,0 +1,61 @@ +{ + "order": [ + "/tests/diagnostics/js-block-redeclaration.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "Class declaration cannot implement overload list for 'collide'.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 386, + "end": 393 + } + }, + { + "code": "SC0001", + "message": "Function with bodies can only merge with classes that are ambient.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 414, + "end": 421 + } + }, + { + "code": "SC0001", + "message": "Type '2' is not comparable to type '1'.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 491, + "end": 492 + } + }, + { + "code": "SC0001", + "message": "Cannot initialize outer scoped variable 'scoped' in the same scope as block scoped declaration 'scoped'.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 502, + "end": 508 + } + }, + { + "code": "SC0006", + "message": "the lexical declaration 'scoped' collides with a 'var' declaration of the same name in this block", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 461, + "end": 467 + } + }, + { + "code": "SC0005", + "message": "the declaration 'collide' is already declared in this block's statement list", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 399, + "end": 426 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json new file mode 100644 index 000000000..4dcb13d8f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json @@ -0,0 +1,70 @@ +{ + "order": [ + "/tests/diagnostics/js-single-statement-declaration.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "A label is not allowed here.", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 405, + "end": 406 + } + }, + { + "code": "SC0001", + "message": "A label is not allowed here.", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 471, + "end": 473 + } + }, + { + "code": "SC0007", + "message": "an async function or generator declaration can only appear at the top level or inside a block", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 505, + "end": 529 + } + }, + { + "code": "SC0007", + "message": "a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 475, + "end": 501 + } + }, + { + "code": "SC0007", + "message": "an async function or generator declaration can only appear at the top level or inside a block", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 435, + "end": 459 + } + }, + { + "code": "SC0007", + "message": "a class declaration cannot appear in a single-statement position", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 408, + "end": 424 + } + }, + { + "code": "SC0007", + "message": "a function declaration can only appear at the top level, inside a block, or as the body of an if statement", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 384, + "end": 404 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json new file mode 100644 index 000000000..d70f7bb28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json @@ -0,0 +1,34 @@ +{ + "order": [ + "/tests/diagnostics/js-single-statement-strict.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "A label is not allowed here.", + "loc": { + "file": "/tests/diagnostics/js-single-statement-strict.js", + "start": 355, + "end": 356 + } + }, + { + "code": "SC0007", + "message": "strict mode code may not declare a function in a single-statement position", + "loc": { + "file": "/tests/diagnostics/js-single-statement-strict.js", + "start": 358, + "end": 379 + } + }, + { + "code": "SC0007", + "message": "strict mode code may not declare a function in a single-statement position", + "loc": { + "file": "/tests/diagnostics/js-single-statement-strict.js", + "start": 336, + "end": 354 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json new file mode 100644 index 000000000..edbbc475e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/sloppy-implicit-global-write.js" + ], + "diags": [ + { + "code": "SC1091", + "message": "writing the undeclared name 'implicitGlobal' in a JavaScript source is not supported yet", + "loc": { + "file": "/tests/diagnostics/sloppy-implicit-global-write.js", + "start": 306, + "end": 320 + }, + "milestone": "later", + "hint": "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)" + } + ] +} From e86800de677fa737359c264780775aa9e7a28f02 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:17:01 -0700 Subject: [PATCH 078/193] Spread unions with several record arms An object spread of a union with several record arms was refused. Two cases are now lowered. Where the checker narrowed the source to fewer arms than its stored union holds (an optional-chain discriminant test on an element read, a type predicate), the source converts through the checked narrowing, so an excluded arm throws the catchable TypeError, and the narrowed record spreads as before. A union spread into one declared record copies each arm's own keys behind its tag test: a key the running arm lacks keeps the earlier value or stays absent. One record arm emits the same code as before. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 501 ++++++++++-------- .../tests/corpus/record-union-spreads.ts.json | 6 + tests/corpus/record-union-spreads.ts | 92 ++++ .../corpus-costs/record-union-spreads.ts.json | 1 + 4 files changed, 376 insertions(+), 224 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json create mode 100644 tests/corpus/record-union-spreads.ts create mode 100644 tests/harness/corpus-costs/record-union-spreads.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 9699a4fb9..f6a3a58c9 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1881,13 +1881,17 @@ export function lowerObjectLiteral( let srcNode: ts.Expression = prop.expression; while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; const condRecord = conditionalRecordSpreadOf(lowerer, prop.expression); - const srcLowered = capture( + let srcLowered = capture( condRecord !== null ? lowerConditionalRecordSpread(lowerer, condRecord, locOf(prop)) : prop === expr.properties.nodes[0] && leadingSpreadLowered !== null ? leadingSpreadLowered : lowerer.lowerExpr(srcNode), ); + { + const narrowed = checkedSpreadSource(lowerer, srcNode, srcLowered); + if (narrowed !== null) srcLowered = capture(narrowed); + } // Array callbacks can receive a record-or-undefined ABI even when // TypeScript describes a required record. Select the copy strategy // from that stored representation so absent sources copy nothing. @@ -1909,7 +1913,7 @@ export function lowerObjectLiteral( const recArms = def?.arms.filter((a) => a.kind === "record") ?? []; if ( !def || - recArms.length !== 1 || + recArms.length === 0 || !def.arms.every((a) => a.kind === "record" || isUnitType(a)) ) { lowerer.unsupported( @@ -1918,8 +1922,6 @@ export function lowerObjectLiteral( `object spread of '${lowerer.fmt(srcType)}' sources (only known record shapes spread — ${NARROW_FIRST})`, ); } - const recArm = recArms[0]! as IrType & { kind: "record" }; - const recTag = def.arms.indexOf(recArm); // A checked-dynamic VALUE under the union-mapped checker type // (a JS dyn-holding binding): the present/absent desugar tests // union tags a dyn box does not carry — fence honestly instead @@ -1932,242 +1934,218 @@ export function lowerObjectLiteral( `object spread of a checked-dynamic '${lowerer.fmt(srcType)}' source (${NARROW_FIRST})`, ); } - const srcShape = lowerer.shapes.get(recArm.shapeId); - if (!srcShape) - throw new InternalCompilerError(`lowerer bug: spread of unknown shape ${recArm.shapeId}`); - fenceAccessorSpreadSource(lowerer, prop, srcShape); - if (srcShape.indexValue || shape.indexValue) { - const fallback = dynSpreadLiteral(lowerer, expr, type); - if (fallback) return fallback; - lowerer.unsupported( - "SC1090", - prop, - "object spread involving index-signature shapes (overflow keys are runtime state — copy the fields you need explicitly)", - ); - } - const laterNames = new Set(); - for (const later of expr.properties.nodes.slice(expr.properties.nodes.indexOf(prop) + 1)) { - if (ts.isSpreadAssignment(later)) { - if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression)) - continue; - const lt = laterSpreadType(lowerer, later.expression); - if (lt?.kind === "record") { - // A field the later source may lack defines nothing. - for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) - if (!canLackField(lf.type)) laterNames.add(lf.name); - } - continue; - } - if ( - later.name && - (ts.isIdentifier(later.name) || - ts.isStringLiteral(later.name) || - ts.isNumericLiteral(later.name) || - (ts.isComputedPropertyName(later.name) && - literalComputedKey(lowerer, later.name) !== null)) - ) { - laterNames.add(propNameText(lowerer, later.name)); - } - } - const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode); - const srcNarrowed = (): IrExpr => ({ - kind: "unionNarrow", - unionId: srcType.unionId, - tag: recTag, - value: srcRef(), - type: recArm, - loc: locOf(prop), - }); - // The source PROVEN present: the checked extraction `x!` lowers to - // (narrowedArmHelper + checkedNarrowCall). A required target field - // with no earlier contributor has no absent representation, so - // `{ ...maybeRecord }` into it must find the record: the unit arm - // throws the catchable TypeError instead of leaving the slot unset - // (divergence 38's lying-assertion stance — the checker typed the - // spread's fields optional, so only a cast or an unchecked indexed - // read reaches this shape). - const srcPresent = (): IrExpr => { - const helper = lowerer.narrowedArmHelper(srcType.unionId, recArm, locOf(prop)); - return helper - ? lowerer.checkedNarrowCall(helper, srcRef(), recArm, locOf(prop)) - : srcNarrowed(); - }; - orderSegments.push({ kind: "spread", shapeId: recArm.shapeId, maybeAbsent: true }); - for (const f of srcShape.fields) { - if (laterNames.has(f.name)) continue; - const targetType = fieldTypes.get(f.name); - // No slot on the target shape: the copy DROPS the field - // (divergence 36's stance, same as the plain-record spread). - if (!targetType) continue; - if (conditionalNames.has(f.name)) { - const conditionalDyn = conditionalFallback(); - if (conditionalDyn) return conditionalDyn; + // Several record arms: each arm contributes its own fields behind its + // tag test, wrapping the earlier contributor, so a key that arm lacks + // keeps the earlier value (JS copies only the arm's own keys). + for (const recArmCandidate of recArms) { + const recArm = recArmCandidate as IrType & { kind: "record" }; + const recTag = def.arms.indexOf(recArm); + const srcShape = lowerer.shapes.get(recArm.shapeId); + if (!srcShape) + throw new InternalCompilerError( + `lowerer bug: spread of unknown shape ${recArm.shapeId}`, + ); + fenceAccessorSpreadSource(lowerer, prop, srcShape); + if (srcShape.indexValue || shape.indexValue) { + const fallback = dynSpreadLiteral(lowerer, expr, type); + if (fallback) return fallback; lowerer.unsupported( "SC1090", prop, - `spread of '${f.name}' over an earlier conditional spread (the desugar keeps one entry per name — restructure so each name has one contributor)`, + "object spread involving index-signature shapes (overflow keys are runtime state — copy the fields you need explicitly)", ); } - const fRead = (present = false): IrExpr => ({ - kind: "recordGet", - obj: present ? srcPresent() : srcNarrowed(), - shapeId: recArm.shapeId, - field: f.name, - type: f.type, - loc: locOf(prop), - }); - let cond: IrExpr = { - kind: "unionIsTag", + const laterNames = new Set(); + for (const later of expr.properties.nodes.slice( + expr.properties.nodes.indexOf(prop) + 1, + )) { + if (ts.isSpreadAssignment(later)) { + if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression)) + continue; + const lt = laterSpreadType(lowerer, later.expression); + if (lt?.kind === "record") { + // A field the later source may lack defines nothing. + for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) + if (!canLackField(lf.type)) laterNames.add(lf.name); + } + continue; + } + if ( + later.name && + (ts.isIdentifier(later.name) || + ts.isStringLiteral(later.name) || + ts.isNumericLiteral(later.name) || + (ts.isComputedPropertyName(later.name) && + literalComputedKey(lowerer, later.name) !== null)) + ) { + laterNames.add(propNameText(lowerer, later.name)); + } + } + const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode); + const srcNarrowed = (): IrExpr => ({ + kind: "unionNarrow", unionId: srcType.unionId, tag: recTag, - negated: false, value: srcRef(), - type: BOOL, - loc: locOf(prop), - }; - let thenVal: IrExpr; - // A source field that can be absent copies only when present. - const fieldPresent = (): IrExpr => ({ - kind: "logical", - op: "&&", - left: cond, - right: { - kind: "recordHas", - obj: { - kind: "unionNarrow", - unionId: srcType.unionId, - tag: recTag, - value: srcRef(), - type: recArm, - loc: locOf(prop), - }, - shapeId: recArm.shapeId, - field: f.name, - type: BOOL, - loc: locOf(prop), - }, - type: BOOL, + type: recArm, loc: locOf(prop), }); - const canBeAbsent = - f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0; - if (typeEquals(f.type, targetType)) { - thenVal = fRead(); - if (canBeAbsent) cond = fieldPresent(); - } else if ( - f.type.kind === "union" && - lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 && - typeEquals(lowerer.stripUndefinedArm(f.type), targetType) - ) { - // Optional source field into a required target slot: present - // means the source holds the record AND the field its value - // arm — the spread-override completion, union-source form. - const ftUndef = lowerer.armTag(f.type.unionId, UNDEFINED_T); - cond = { + // The source PROVEN present: the checked extraction `x!` lowers to + // (narrowedArmHelper + checkedNarrowCall). A required target field + // with no earlier contributor has no absent representation, so + // `{ ...maybeRecord }` into it must find the record: the unit arm + // throws the catchable TypeError instead of leaving the slot unset + // (divergence 38's lying-assertion stance — the checker typed the + // spread's fields optional, so only a cast or an unchecked indexed + // read reaches this shape). + const srcPresent = (): IrExpr => { + const helper = lowerer.narrowedArmHelper(srcType.unionId, recArm, locOf(prop)); + return helper + ? lowerer.checkedNarrowCall(helper, srcRef(), recArm, locOf(prop)) + : srcNarrowed(); + }; + orderSegments.push({ kind: "spread", shapeId: recArm.shapeId, maybeAbsent: true }); + for (const f of srcShape.fields) { + if (laterNames.has(f.name)) continue; + const targetType = fieldTypes.get(f.name); + // No slot on the target shape: the copy DROPS the field + // (divergence 36's stance, same as the plain-record spread). + if (!targetType) continue; + if (conditionalNames.has(f.name)) { + const conditionalDyn = conditionalFallback(); + if (conditionalDyn) return conditionalDyn; + lowerer.unsupported( + "SC1090", + prop, + `spread of '${f.name}' over an earlier conditional spread (the desugar keeps one entry per name — restructure so each name has one contributor)`, + ); + } + const fRead = (present = false): IrExpr => ({ + kind: "recordGet", + obj: present ? srcPresent() : srcNarrowed(), + shapeId: recArm.shapeId, + field: f.name, + type: f.type, + loc: locOf(prop), + }); + let cond: IrExpr = { + kind: "unionIsTag", + unionId: srcType.unionId, + tag: recTag, + negated: false, + value: srcRef(), + type: BOOL, + loc: locOf(prop), + }; + let thenVal: IrExpr; + // A source field that can be absent copies only when present. + const fieldPresent = (): IrExpr => ({ kind: "logical", op: "&&", left: cond, right: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: ftUndef, - negated: true, - value: fRead(), + kind: "recordHas", + obj: { + kind: "unionNarrow", + unionId: srcType.unionId, + tag: recTag, + value: srcRef(), + type: recArm, + loc: locOf(prop), + }, + shapeId: recArm.shapeId, + field: f.name, type: BOOL, loc: locOf(prop), }, type: BOOL, loc: locOf(prop), - }; - const ftDef = lowerer.unions.get(f.type.unionId); - if (targetType.kind === "union") { - const retag = lowerer.unionRetagHelper( - f.type.unionId, - targetType.unionId, - locOf(prop), - ); - if (!retag) { + }); + const canBeAbsent = + f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0; + if (typeEquals(f.type, targetType)) { + thenVal = fRead(); + if (canBeAbsent) cond = fieldPresent(); + } else if ( + f.type.kind === "union" && + lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 && + typeEquals(lowerer.stripUndefinedArm(f.type), targetType) + ) { + // Optional source field into a required target slot: present + // means the source holds the record AND the field its value + // arm — the spread-override completion, union-source form. + const ftUndef = lowerer.armTag(f.type.unionId, UNDEFINED_T); + cond = { + kind: "logical", + op: "&&", + left: cond, + right: { + kind: "unionIsTag", + unionId: f.type.unionId, + tag: ftUndef, + negated: true, + value: fRead(), + type: BOOL, + loc: locOf(prop), + }, + type: BOOL, + loc: locOf(prop), + }; + const ftDef = lowerer.unions.get(f.type.unionId); + if (targetType.kind === "union") { + const retag = lowerer.unionRetagHelper( + f.type.unionId, + targetType.unionId, + locOf(prop), + ); + if (!retag) { + lowerer.pushDiag( + recordShapeMismatchDiag( + lowerer.fmt(type), + lowerer.fmt(recArm), + locOf(prop), + `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot re-tag into '${lowerer.fmt(targetType)}' behind the present-test`, + ), + ); + throw new PoisonError(); + } + thenVal = { + kind: "call", + callee: retag, + args: [fRead()], + type: targetType, + loc: locOf(prop), + }; + } else if ( + ftDef && + ftDef.arms.length === 2 && + lowerer.armTag(f.type.unionId, targetType) >= 0 + ) { + thenVal = { + kind: "unionNarrow", + unionId: f.type.unionId, + tag: lowerer.armTag(f.type.unionId, targetType), + value: fRead(), + type: targetType, + loc: locOf(prop), + }; + } else { lowerer.pushDiag( recordShapeMismatchDiag( lowerer.fmt(type), lowerer.fmt(recArm), locOf(prop), - `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot re-tag into '${lowerer.fmt(targetType)}' behind the present-test`, + `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot narrow into '${lowerer.fmt(targetType)}' behind the present-test`, ), ); throw new PoisonError(); } - thenVal = { - kind: "call", - callee: retag, - args: [fRead()], - type: targetType, - loc: locOf(prop), - }; - } else if ( - ftDef && - ftDef.arms.length === 2 && - lowerer.armTag(f.type.unionId, targetType) >= 0 - ) { - thenVal = { - kind: "unionNarrow", - unionId: f.type.unionId, - tag: lowerer.armTag(f.type.unionId, targetType), - value: fRead(), - type: targetType, - loc: locOf(prop), - }; } else { - lowerer.pushDiag( - recordShapeMismatchDiag( - lowerer.fmt(type), - lowerer.fmt(recArm), - locOf(prop), - `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot narrow into '${lowerer.fmt(targetType)}' behind the present-test`, - ), - ); - throw new PoisonError(); - } - } else { - // The width-lift fallback (arm wrap, re-tag, nested - // reshape) — the same per-field rule the slot coercion - // applies. Runs AFTER the optional-completion branch: a - // present-test has its own semantics a re-tag's stranded - // undefined trap must not shadow. - const lift = lowerer.widthLiftPlan(f.type, targetType); - if (!lift) { - lowerer.pushDiag( - recordShapeMismatchDiag( - lowerer.fmt(type), - lowerer.fmt(recArm), - locOf(prop), - `spread field '${f.name}': '${lowerer.fmt(f.type)}' does not lift into '${lowerer.fmt(targetType)}'`, - ), - ); - throw new PoisonError(); - } - thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop)); - if (canBeAbsent) cond = fieldPresent(); - } - const at = fields.findIndex((x) => x.name === f.name); - const elseVal = - at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop)); - if (!elseVal) { - if (canBeAbsent) { - // The FIELD itself can be missing from a present source: - // no extraction makes it exist. - lowerer.unsupported( - "SC1090", - prop, - `object spread of '${lowerer.fmt(srcType)}' sources where '${f.name}' has no earlier contributor (the absent arm leaves the required field unset — spread defaults first: { ...defaults, ...overrides })`, - ); - } - // No earlier contributor and the target slot cannot be absent: - // the source must be here. Copy through the checked extraction - // (the absent unit arm throws) — the same read the width lift - // below wraps, so a lift still applies. - let strict = fRead(true); - if (!typeEquals(f.type, targetType)) { + // The width-lift fallback (arm wrap, re-tag, nested + // reshape) — the same per-field rule the slot coercion + // applies. Runs AFTER the optional-completion branch: a + // present-test has its own semantics a re-tag's stranded + // undefined trap must not shadow. const lift = lowerer.widthLiftPlan(f.type, targetType); if (!lift) { lowerer.pushDiag( @@ -2180,21 +2158,56 @@ export function lowerObjectLiteral( ); throw new PoisonError(); } - strict = lowerer.applyWidthLift(lift, strict, targetType, locOf(prop)); + thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop)); + if (canBeAbsent) cond = fieldPresent(); } - fields.push({ name: f.name, value: strict }); - continue; + const at = fields.findIndex((x) => x.name === f.name); + const elseVal = + at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop)); + if (!elseVal) { + if (canBeAbsent) { + // The FIELD itself can be missing from a present source: + // no extraction makes it exist. + lowerer.unsupported( + "SC1090", + prop, + `object spread of '${lowerer.fmt(srcType)}' sources where '${f.name}' has no earlier contributor (the absent arm leaves the required field unset — spread defaults first: { ...defaults, ...overrides })`, + ); + } + // No earlier contributor and the target slot cannot be absent: + // the source must be here. Copy through the checked extraction + // (the absent unit arm throws) — the same read the width lift + // below wraps, so a lift still applies. + let strict = fRead(true); + if (!typeEquals(f.type, targetType)) { + const lift = lowerer.widthLiftPlan(f.type, targetType); + if (!lift) { + lowerer.pushDiag( + recordShapeMismatchDiag( + lowerer.fmt(type), + lowerer.fmt(recArm), + locOf(prop), + `spread field '${f.name}': '${lowerer.fmt(f.type)}' does not lift into '${lowerer.fmt(targetType)}'`, + ), + ); + throw new PoisonError(); + } + strict = lowerer.applyWidthLift(lift, strict, targetType, locOf(prop)); + } + fields.push({ name: f.name, value: strict }); + continue; + } + const merged: IrExpr = { + kind: "ternary", + cond, + then: thenVal, + else_: elseVal, + type: targetType, + loc: locOf(prop), + }; + if (at >= 0) fields[at] = { name: f.name, value: merged }; + else fields.push({ name: f.name, value: merged }); } - const merged: IrExpr = { - kind: "ternary", - cond, - then: thenVal, - else_: elseVal, - type: targetType, - loc: locOf(prop), - }; - if (at >= 0) fields[at] = { name: f.name, value: merged }; - else fields.push({ name: f.name, value: merged }); } continue; } @@ -2620,6 +2633,32 @@ export function lowerObjectLiteral( : { kind: "seqExpr", stmts: spreadStmts, result, type, loc }; } +/** A spread source whose stored union holds several record arms while the + * checker narrowed this site to fewer (`if (b?.type === "tool") ({ ...b })` + * on an element read, or a type predicate's arm): extract the narrowed + * value through the CHECKED narrowing, so every arm the checker excludes, + * including a hidden absence arm, throws the catchable TypeError instead of + * being copied. Null when the stored union has at most one record arm (the + * present-test desugar owns that case), when the site type is not a + * narrowing of the stored union, or in JavaScript files. */ +function checkedSpreadSource(lowerer: Lowerer, node: ts.Expression, stored: IrExpr): IrExpr | null { + if (stored.type.kind !== "union") return null; + if (isJsSourceFile(ts.getSourceFileOfNode(node))) return null; + const def = lowerer.unions.get(stored.type.unionId); + if (!def || def.arms.filter((arm) => arm.kind === "record").length < 2) return null; + const site = lowerer.mapTypeOf(lowerer.typeOf(node)); + if (!site || typeEquals(site, stored.type)) return null; + const loc = stored.loc; + if (site.kind === "record") { + if (lowerer.armTag(stored.type.unionId, site) < 0) return null; + const helper = lowerer.narrowedArmHelper(stored.type.unionId, site, loc); + return helper ? lowerer.checkedNarrowCall(helper, stored, site, loc) : null; + } + if (site.kind !== "union") return null; + const helper = lowerer.narrowedRetagHelper(node, stored.type.unionId, site.unionId, loc); + return helper ? { kind: "call", callee: helper, args: [stored], type: site, loc } : null; +} + /** The field type is an array, or a union with exactly one array arm. */ function singleArrayArm(lowerer: Lowerer, type: IrType): boolean { if (type.kind === "array") return true; @@ -2753,6 +2792,20 @@ function lowerUnionRecordClone(lowerer: Lowerer, expr: ts.ObjectLiteralExpressio }; } } + // Any other source (a capture, a runtime-optional binding, a member + // read) converts through the same checked re-tag: the arms the site's + // checker type excludes, a hidden absence arm included, throw the + // catchable TypeError instead of being cloned. + if (!typeEquals(source.type, sourceType) && source.type.kind === "union") { + const helper = lowerer.narrowedRetagHelper( + spread.expression, + source.type.unionId, + sourceType.unionId, + source.loc, + ); + if (helper) + source = { kind: "call", callee: helper, args: [source], type: sourceType, loc: source.loc }; + } if (!typeEquals(source.type, sourceType)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json new file mode 100644 index 000000000..d468d35ae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-union-spreads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-union-spreads.ts b/tests/corpus/record-union-spreads.ts new file mode 100644 index 000000000..f3698826e --- /dev/null +++ b/tests/corpus/record-union-spreads.ts @@ -0,0 +1,92 @@ +// Object spreads of unions with several record arms. +// 1. The checker narrowed the source to fewer arms than its stored union +// holds (an optional-chain discriminant test on an element read, a type +// predicate): the spread copies the narrowed arm. +// 2. A union source spreads into one declared record: each arm copies only +// the keys it holds, so a key the running arm lacks keeps the earlier +// value or stays absent. +// Key order is printed sorted: per-arm creation order is a separate +// (pre-existing) key-order limitation. + +type Block = + | { type: "text"; text: string; textSignature?: string } + | { type: "tool"; id: string; args: unknown; ns?: string } + | { type: "image"; data: string }; + +function keys(value: object): string { + return Object.keys(value).sort().join(","); +} + +function update(content: Block[], index: number, args: unknown): string { + const block = content[index]; + if (block?.type === "tool") { + content[index] = { ...block, args }; + return "updated"; + } + return "skipped"; +} + +function isTool(block: Block): block is Block & { type: "tool" } { + return block.type === "tool"; +} + +function relabel(content: readonly Block[]): Block[] { + return content.map((block) => (isTool(block) ? { ...block, args: "relabelled" } : block)); +} + +const blocks: Block[] = [ + { type: "tool", id: "1", args: 0 }, + { type: "text", text: "t" }, + { type: "tool", id: "2", args: 0, ns: "n" }, + { type: "image", data: "d" }, +]; +console.log(update(blocks, 0, 5), update(blocks, 1, 5), update(blocks, 2, [1]), update(blocks, 9, 5)); +for (const block of blocks) { + if (block.type === "tool") console.log(block.id, JSON.stringify(block.args), block.ns, keys(block)); + else console.log(block.type, keys(block)); +} +for (const block of relabel(blocks)) { + if (block.type === "tool") console.log(block.id, JSON.stringify(block.args), keys(block)); +} + +interface RequestOptions { + apiKey?: string; + headers?: Record; + env?: string; + maxTokens?: number; + label?: string; +} + +function describe(options: RequestOptions): string { + return [ + options.apiKey, + options.headers?.["x"], + options.env, + options.maxTokens, + options.label, + keys(options), + "apiKey" in options, + ].join(" "); +} + +// The `cond ? { a, b } : {}` credential idiom. +function route(sameProvider: boolean, apiKey: string | undefined, env: string | undefined): string { + const auth = sameProvider ? { apiKey, env } : {}; + return describe({ label: "base", ...auth, maxTokens: 5 }); +} +console.log(route(true, "k", "e")); +console.log(route(true, undefined, "e")); +console.log(route(false, "k", "e")); + +type Source = { env?: string; apiKey: string } | { label: string; headers?: Record } | undefined; + +function merge(base: RequestOptions, source: Source): string { + return describe({ ...base, ...source, maxTokens: 1 }); +} +console.log(merge({ headers: { x: "h0" } }, { apiKey: "k", env: "e" })); +console.log(merge({ headers: { x: "h0" }, apiKey: "k0" }, { apiKey: "k" })); +console.log(merge({ headers: { x: "h0" } }, { label: "l" })); +console.log(merge({ headers: { x: "h0" } }, { label: "l", headers: { x: "h1" } })); +console.log(merge({ env: "e0" }, undefined)); + +export {}; diff --git a/tests/harness/corpus-costs/record-union-spreads.ts.json b/tests/harness/corpus-costs/record-union-spreads.ts.json new file mode 100644 index 000000000..d2e79152f --- /dev/null +++ b/tests/harness/corpus-costs/record-union-spreads.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 7052 } From 5a6f2c0902a564368f1aa032c9a677d0b63d4ebe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:17:05 -0700 Subject: [PATCH 079/193] Destructure `options ?? {}` over records with required members `const { a, ...rest } = options ?? {}` destructures the options record or an empty object. Where the record had a required member, the `{}` fallback was coerced into the record and failed with SC2002. The source now lowers to the union of the record and the empty record, so the union pattern lowering reads each key from the arm that holds it, the empty arm answers undefined and the rest element packs nothing. A record arm without a literal key now answers undefined in unionKeyGet too, which the frontend already asked for and the validator rejected as an internal error. All-optional options keep their lowering. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/expr-dynamic.ts | 13 ++ .../src/frontend/lowering/lower-stmts.ts | 2 + .../lowering/nullish-empty-pattern.ts | 136 ++++++++++++++++++ packages/compiler/src/ir/validate.test.ts | 51 +++++++ packages/compiler/src/ir/validate.ts | 8 ++ .../record-nullish-empty-destructure.ts.json | 6 + .../record-nullish-empty-destructure.ts | 83 +++++++++++ .../record-nullish-empty-destructure.ts.json | 1 + 8 files changed, 300 insertions(+) create mode 100644 packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json create mode 100644 tests/corpus/record-nullish-empty-destructure.ts create mode 100644 tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 7f4b7e48a..0fb9ea4ff 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -547,6 +547,19 @@ export function emitDynamicExpr( B.br(join); return; } + // A declared-only arm without the literal key: the object has no + // such own property, so the read answers undefined. + if (literal !== null && !shape.indexValue) { + const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1; + if (tag < 0 || e.type.kind !== "union") { + throw new InternalCompilerError( + "llvm emitter bug: unionKeyGet missing key without an undefined result arm", + ); + } + B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`); + B.br(join); + return; + } // Index-signature arm (or declared-only shape under a runtime // key): the shared keyed-read chain — a literal key naming no // declared field touches only the overflow map. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28d7e7c22..0e20791fd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; +import { nullishEmptyPatternSource } from "./nullish-empty-pattern.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -2485,6 +2486,7 @@ export function lowerDestructuringDecl( } let init = importMetaBindingSource(lowerer, decl.name, decl.initializer) ?? + nullishEmptyPatternSource(lowerer, decl.name, decl.initializer) ?? lowerer.lowerExpr(decl.initializer); const parseArgsDynObject = init.type.kind === "dyn" && diff --git a/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts new file mode 100644 index 000000000..66c1f00e7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts @@ -0,0 +1,136 @@ +import { BOOL, type IrExpr, type IrType, isUnitType, typeKey } from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +function stripParens(node: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return node; +} + +/** `const { a, ...rest } = options ?? {}` (also `|| {}`) where the options + * record has a member that cannot be absent. The pattern destructures + * either the record or an empty object, which has no own keys, so the + * source lowers to a union of the record and the empty record: the union + * pattern lowering reads each key from the arm that holds it and answers + * undefined from the empty arm, and a rest element packs the record's + * remaining fields or nothing. The options operand is evaluated once. + * + * Null (the caller keeps its current lowering) for JavaScript files, other + * initializer forms, an operand the checker does not type as one record + * plus nullish arms, and a record whose every field may be absent: the + * existing lowering already builds the empty object as that record with + * every key absent. */ +export function nullishEmptyPatternSource( + lowerer: Lowerer, + pattern: ts.BindingName, + initializer: ts.Expression, +): IrExpr | null { + if (!ts.isObjectBindingPattern(pattern)) return null; + if (isJsSourceFile(ts.getSourceFileOfNode(initializer))) return null; + const init = stripParens(initializer); + if ( + !ts.isBinaryExpression(init) || + (init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + init.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return null; + const right = stripParens(init.right); + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return null; + const loc = locOf(init); + // Decide from the checker's view of the operand before lowering it once. + const declared = lowerer.mapTypeOf(lowerer.typeOf(init.left)); + if (declared?.kind !== "union") return null; + const declaredArms = lowerer.unions.get(declared.unionId)?.arms ?? []; + const declaredRecords = declaredArms.filter((arm) => arm.kind === "record"); + if ( + declaredRecords.length !== 1 || + !declaredArms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return null; + const declaredShape = lowerer.shapes.get( + (declaredRecords[0] as IrType & { kind: "record" }).shapeId, + ); + if ( + !declaredShape || + declaredShape.tuple || + declaredShape.indexValue || + declaredShape.fields.length === 0 || + declaredShape.fields.every( + (field) => lowerer.absentFieldValue(field.type, loc)?.kind === "fieldAbsent", + ) + ) + return null; + const left = lowerer.lowerExpr(init.left); + // The stored operand can differ from the checker's view. A stored record + // is never nullish (the fallback is dead); any other stored form must be + // the same one record plus nullish arms. + if (left.type.kind === "record") return left; + if (left.type.kind !== "union") { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const sourceId = left.type.unionId; + const arms = lowerer.unions.get(sourceId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if (records.length !== 1 || !arms.every((arm) => arm.kind === "record" || isUnitType(arm))) { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const record = records[0] as IrType & { kind: "record" }; + const empty: IrType = { kind: "record", shapeId: lowerer.shapes.intern([]) }; + const resultArms = [record, empty].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const resultType: IrType = { kind: "union", unionId: lowerer.unions.intern(resultArms) }; + const sourceTag = arms.indexOf(record); + const tmp = lowerer.declareHiddenLocal("%nullishEmpty", left.type); + const ref: IrExpr = { kind: "varRef", localId: tmp.id, type: left.type, loc }; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: tmp.id, init: left, loc }], + result: { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: sourceId, + tag: sourceTag, + negated: false, + value: ref, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(record), + value: { + kind: "unionNarrow", + unionId: sourceId, + tag: sourceTag, + value: ref, + type: record, + loc, + }, + type: resultType, + loc, + }, + else_: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(empty), + value: { kind: "recordLit", fields: [], type: empty, loc }, + type: resultType, + loc, + }, + type: resultType, + loc, + }, + type: resultType, + loc, + }; +} diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 83e0f9a31..727d350af 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -2183,3 +2183,54 @@ test("a caught-typed global records a module error only from a catch binding", ( 'in main: assign to catch binding "%error" (frontend must reject)', ]); }); + +test("a record arm without the literal key answers undefined in unionKeyGet", () => { + const uX: IrType = { kind: "union", unionId: "uX" }; + const uS: IrType = { kind: "union", unionId: "uS" }; + const rA: IrType = { kind: "record", shapeId: "rA" }; + const module = (answer: IrType): IrModule => ({ + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: [ + { id: "rA", fields: [{ name: "x", type: uX }] }, + { id: "rE", fields: [] }, + ], + unions: [ + { id: "uX", arms: [STRING, UNDEFINED_T] }, + { id: "uS", arms: [rA, { kind: "record", shapeId: "rE" }] }, + ], + functions: [ + { + name: "main", + params: [{ localId: "p", name: "p", type: uS }], + locals: [{ id: "p", name: "p", type: uS, mutable: false }], + returnType: answer, + body: [ + { + kind: "return", + value: { + kind: "unionKeyGet", + unionId: "uS", + key: { kind: "strLit", value: "x", type: STRING, loc }, + value: { kind: "varRef", localId: "p", type: uS, loc }, + type: answer, + loc, + }, + loc, + }, + ], + loc, + }, + ], + }); + // The empty arm lacks "x": it has no such own property, so it answers + // the result's undefined arm. + expect(validateModule(module(uX))).toEqual([]); + // Without an undefined arm in the result there is no answer to give. + expect( + validateModule(module(STRING)).some((d) => + d.message.includes("unionKeyGet: arm 1 of uS has no answer for the key"), + ), + ).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..4c563d729 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3869,6 +3869,14 @@ function validateFunction( } return; } + // A declared-only arm that lacks a literal key has no such own + // property: it answers undefined, like a unit arm. + if ( + literal !== null && + !shape.indexValue && + resultUnion?.arms.some((a) => a.kind === "undefinedT") + ) + return; if (!shape.indexValue && !(literal === null && shape.fields.length > 0)) { err(`unionKeyGet: arm ${i} of ${e.unionId} has no answer for the key`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..e7da7aa73 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-nullish-empty-destructure.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-nullish-empty-destructure.ts b/tests/corpus/record-nullish-empty-destructure.ts new file mode 100644 index 000000000..aff75af76 --- /dev/null +++ b/tests/corpus/record-nullish-empty-destructure.ts @@ -0,0 +1,83 @@ +// `const { a, ...rest } = options ?? {}` (and `|| {}`): the pattern +// destructures either the options record or an empty object. Each key reads +// undefined from the empty object, a default applies to it, and the rest +// element packs the record's remaining own keys or nothing. The options +// expression is evaluated once. + +interface EditorOptions { + description: string; + paddingX: number; + label?: string; +} + +function editor(options?: EditorOptions): string { + const { description, ...editorOptions } = options ?? {}; + return `${description} ${JSON.stringify(editorOptions)} [${Object.keys(editorOptions).join(",")}]`; +} +console.log(editor()); +console.log(editor({ description: "d", paddingX: 2 })); +console.log(editor({ description: "d", paddingX: 2, label: "l" })); + +// Every member optional: the empty object is the same layout with every +// key absent. +interface StreamOptions { + apiKey?: string; + env?: string; + signal?: string; + transformHeaders?: (headers: string) => string; +} + +function stream(options?: StreamOptions): string { + const { transformHeaders, apiKey, ...rest } = options ?? {}; + const headers = transformHeaders ? transformHeaders("h") : "none"; + return `${headers} ${apiKey} ${JSON.stringify(rest)} ${"signal" in rest}`; +} +console.log(stream()); +console.log(stream({ apiKey: "k", signal: "s" })); +console.log(stream({ transformHeaders: (h) => h + "!", env: "e" })); + +// A nested optional record, renamed keys and a default. +interface Entry { + options?: { deferred: boolean; signal?: string; step: number }; +} + +function submit(entry: Entry): string { + const { deferred: _deferred, signal: _signal, step = -1, ...submission } = entry.options ?? {}; + return `${step} ${JSON.stringify(submission)}`; +} +console.log(submit({})); +console.log(submit({ options: { deferred: true, step: 4 } })); +console.log(submit({ options: { deferred: false, signal: "x", step: 0 } })); + +// `||` and a null arm. +interface Retry { + headers: string; + retries: number; +} + +function retry(value: Retry | null): string { + const { headers, retries = 3 } = value || {}; + return `${headers} ${retries} ${typeof retries}`; +} +console.log(retry(null)); +console.log(retry({ headers: "h", retries: 2 })); + +// A generic options parameter. +function generic(options?: TOptions): string { + const { apiKey, ...providerOptions } = options ?? {}; + return `${apiKey} ${JSON.stringify(providerOptions)}`; +} +console.log(generic()); +console.log(generic({ maxTokens: 3, apiKey: "k" })); + +// The options expression runs once. +let calls = 0; +function current(): EditorOptions | undefined { + calls++; + return calls === 1 ? undefined : { description: "second", paddingX: 1 }; +} +const { description: first } = current() ?? {}; +const { description: second, paddingX } = current() ?? {}; +console.log(first, second, paddingX, calls); + +export {}; diff --git a/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..c56636be7 --- /dev/null +++ b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json @@ -0,0 +1 @@ +{ "plain": 6298, "san": 72106 } From 980a1af68233381a37b4fcff9b6dc218512f9b3a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:48 -0700 Subject: [PATCH 080/193] Build nested literals at the enclosing literal's own layout An object literal whose context has no layout builds at its own type, but its nested literals still chose their own contextual layout, so the nested record could not convert into the field the outer literal had built, and SC2002 named the source literal's type as the expected one. A nested literal now builds at the layout the enclosing literal asked for when its contextual layout cannot reach it, and the outer coercion lifts the whole tree into the real destination. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 16 ++- .../async-return-contextual-records.ts.json | 6 + .../corpus/nested-literal-own-layout.ts.json | 6 + .../corpus/async-return-contextual-records.ts | 108 ++++++++++++++++++ tests/corpus/nested-literal-own-layout.ts | 20 ++++ .../async-return-contextual-records.ts.json | 1 + .../nested-literal-own-layout.ts.json | 1 + 7 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json create mode 100644 tests/corpus/async-return-contextual-records.ts create mode 100644 tests/corpus/nested-literal-own-layout.ts create mode 100644 tests/harness/corpus-costs/async-return-contextual-records.ts.json create mode 100644 tests/harness/corpus-costs/nested-literal-own-layout.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index f6a3a58c9..ca40a9cf0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1302,7 +1302,21 @@ export function lowerObjectLiteral( lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE); } if (!mapped || mapped.kind !== "record") lowerer.badType(expr, tsType); - let type: IrType = mapped; + // An enclosing literal that built at its OWN type (its context had no + // layout) asks for the matching own layout of this nested literal. When + // this literal's contextual layout cannot reach that slot, build at it: + // the enclosing literal's coercion then decides, and the width lifts + // (the index-signature overflow capture included) carry the whole tree + // into the real destination. Otherwise the two layers disagree and the + // SC2002 names the source literal's own type as the expected one. + const layout: IrType & { kind: "record" } = + expected && + !typeEquals(mapped, expected) && + lowerer.widthLiftPlan(mapped, expected) === null && + typeEquals(lowerer.mapTypeOf(lowerer.typeOf(expr)) ?? mapped, expected) + ? expected + : mapped; + let type: IrType = layout; // A fresh, plain literal can construct its fields directly for a known // destination with the same field set. This matters for inferred // conditional records: an empty array in one arm has no element layout diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json new file mode 100644 index 000000000..64c1702a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/async-return-contextual-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json new file mode 100644 index 000000000..5899185f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/nested-literal-own-layout.ts" + ], + "diags": [] +} diff --git a/tests/corpus/async-return-contextual-records.ts b/tests/corpus/async-return-contextual-records.ts new file mode 100644 index 000000000..f19af35dc --- /dev/null +++ b/tests/corpus/async-return-contextual-records.ts @@ -0,0 +1,108 @@ +// Object literals returned from async arrows typed by a method signature +// (`resolve(input): Promise`), with destructured +// parameters. The return position's contextual type is +// `AuthResult | undefined | PromiseLike`; the +// literal must build as AuthResult so its nested header record reaches the +// pure index record ProviderHeaders (null-valued keys included), exactly as +// in a sync function. +type ProviderHeaders = Record; +interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; +} +interface AuthResult { + auth: ModelAuth; + env?: Record; + source?: string; +} +interface Ctx { + env(name: string): Promise; +} +interface Credential { + type: "api_key"; + key?: string; +} +interface Auth { + name: string; + resolve(input: { ctx: Ctx; credential?: Credential; signal: AbortSignal }): Promise; + peek?(input: { ctx: Ctx }): AuthResult | undefined; +} + +function tokenAuth(): Auth { + return { + name: "token", + resolve: async ({ ctx, credential, signal }) => { + signal.throwIfAborted(); + if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored" }; + const token = await ctx.env("TOKEN"); + if (token) return { auth: { headers: { Authorization: `Bearer ${token}` } }, source: "TOKEN" }; + return undefined; + }, + }; +} + +function gatewayAuth(): Auth { + return { + name: "gateway", + resolve: async ({ ctx }) => { + const key = await ctx.env("KEY"); + if (!key) return undefined; + return { + auth: { + headers: { + "cf-aig-authorization": `Bearer ${key}`, + Authorization: null, + "x-api-key": null, + }, + }, + env: { ACCOUNT: "a1" }, + source: "KEY", + }; + }, + peek: ({ ctx }) => (ctx ? { auth: { headers: { "x-peek": "1" } } } : undefined), + }; +} + +function boundAuth(): Auth { + return { + name: "bound", + resolve: async ({ ctx }) => { + const token = await ctx.env("TOKEN"); + const headers = { Authorization: `Bearer ${token ?? "none"}` }; + return { auth: { headers }, source: "bound" }; + }, + }; +} + +async function plain(flag: boolean): Promise<{ id: string; tags?: Record } | undefined> { + return flag ? { id: "p", tags: { a: "1", b: null } } : undefined; +} + +function show(result: AuthResult | undefined): string { + if (result === undefined) return "undefined"; + const headers = result.auth.headers ?? {}; + const keys = Object.keys(headers).sort(); + return `${result.source ?? "-"} key=${result.auth.apiKey ?? "-"} ${keys.map((k) => `${k}=${String(headers[k])}`).join(" ")} env=${JSON.stringify(result.env ?? null)}`; +} + +async function main(): Promise { + const env = new Map([ + ["KEY", "k1"], + ["TOKEN", "t1"], + ]); + const ctx: Ctx = { env: async (name) => env.get(name) }; + const signal = new AbortController().signal; + const token = tokenAuth(); + console.log(show(await token.resolve({ ctx, credential: { type: "api_key", key: "s1" }, signal }))); + console.log(show(await token.resolve({ ctx, signal }))); + console.log(show(await gatewayAuth().resolve({ ctx, signal }))); + console.log(show(gatewayAuth().peek?.({ ctx }))); + console.log(show(await boundAuth().resolve({ ctx, signal }))); + env.clear(); + console.log(show(await token.resolve({ ctx, signal }))); + console.log(show(await gatewayAuth().resolve({ ctx, signal }))); + console.log(show(await boundAuth().resolve({ ctx, signal }))); + const p = await plain(true); + console.log(p?.id, JSON.stringify(p?.tags), (await plain(false)) === undefined); +} +main(); diff --git a/tests/corpus/nested-literal-own-layout.ts b/tests/corpus/nested-literal-own-layout.ts new file mode 100644 index 000000000..7f313363d --- /dev/null +++ b/tests/corpus/nested-literal-own-layout.ts @@ -0,0 +1,20 @@ +// The outer literal's context (a union with a WeakRef arm) has no layout, +// so it builds at its own type; its nested literals must follow that +// layout instead of their own contextual one, and the outer coercion then +// lifts the whole tree (headers into the index-signature record). +type ProviderHeaders = Record; +interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; +} +interface AuthResult { + auth: ModelAuth; + source?: string; +} +type Answer = AuthResult | WeakRef | undefined; +function make(flag: boolean): Answer { + return flag ? { auth: { headers: { Authorization: "Bearer x" } }, source: "env" } : undefined; +} +const r = make(true) as AuthResult; +console.log(r.source, r.auth.headers?.Authorization); +console.log(make(false) === undefined); diff --git a/tests/harness/corpus-costs/async-return-contextual-records.ts.json b/tests/harness/corpus-costs/async-return-contextual-records.ts.json new file mode 100644 index 000000000..d1cbee83a --- /dev/null +++ b/tests/harness/corpus-costs/async-return-contextual-records.ts.json @@ -0,0 +1 @@ +{ "plain": 9301, "san": 135316 } diff --git a/tests/harness/corpus-costs/nested-literal-own-layout.ts.json b/tests/harness/corpus-costs/nested-literal-own-layout.ts.json new file mode 100644 index 000000000..0c791d2ac --- /dev/null +++ b/tests/harness/corpus-costs/nested-literal-own-layout.ts.json @@ -0,0 +1 @@ +{ "plain": 5031, "san": 84920 } From b0bbae15ac2afcfeb56b328f531bd83c86ef880a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:53 -0700 Subject: [PATCH 081/193] Spread an empty-object default like its source `...(x ?? {})` and `...(x || {})` spread exactly what `...x` spreads, but the defaulted source lowered as a `??` expression into a checked-dynamic value, which failed for records with function fields. When x is a record or an optional record, the spread now lowers as a spread of x. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 47 ++++++++++++++++--- .../tests/corpus/spread-empty-default.ts.json | 6 +++ tests/corpus/spread-empty-default.ts | 45 ++++++++++++++++++ .../corpus-costs/spread-empty-default.ts.json | 1 + 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json create mode 100644 tests/corpus/spread-empty-default.ts create mode 100644 tests/harness/corpus-costs/spread-empty-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index ca40a9cf0..580c3ff95 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -549,8 +549,7 @@ function fenceAccessorSpreadSource( /** Only definitely present later records replace earlier defaults. A * callback binding may store undefined despite its checker annotation. */ function laterSpreadType(lowerer: Lowerer, expr: ts.Expression): IrType | null { - let source = expr; - while (ts.isParenthesizedExpression(source)) source = source.expression; + const source = spreadSourceNode(lowerer, expr); if (ts.isIdentifier(source)) return lowerer.lowerExpr(source).type; return lowerer.mapTypeOf(lowerer.typeOf(source)); } @@ -1892,8 +1891,7 @@ export function lowerObjectLiteral( // earlier ones (JS last-write-wins). Evaluate the source exactly // once, including empty sources and those whose fields are all // overridden. Field snapshots above preserve the copy order. - let srcNode: ts.Expression = prop.expression; - while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; + const srcNode = spreadSourceNode(lowerer, prop.expression); const condRecord = conditionalRecordSpreadOf(lowerer, prop.expression); let srcLowered = capture( condRecord !== null @@ -3022,14 +3020,51 @@ function lowerConditionalRecordSpread( }; } +/** A spread's source expression with parentheses stripped, seeing through + * an empty-object default: `...(x ?? {})` and `...(x || {})` spread exactly + * what `...x` spreads (a nullish or other falsy value is a primitive with + * no own keys, and x evaluates once either way). Only where `x` is a plain + * record or a record-or-unit union, which the field copy and the + * optional-spread desugar handle; index-signature and other sources keep + * their paths. */ +function spreadSourceNode(lowerer: Lowerer, expr: ts.Expression): ts.Expression { + let e: ts.Expression = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if ( + !ts.isBinaryExpression(e) || + (e.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + e.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return e; + let right: ts.Expression = e.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return e; + let left: ts.Expression = e.left; + while (ts.isParenthesizedExpression(left)) left = left.expression; + const plainRecord = (t: IrType | undefined): boolean => + t?.kind === "record" && lowerer.shapes.get(t.shapeId)?.indexValue === undefined; + const source = lowerer.mapTypeOf(lowerer.typeOf(left)); + if (plainRecord(source ?? undefined)) return left; + if (source?.kind === "union") { + const arms = lowerer.unions.get(source.unionId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if ( + records.length === 1 && + plainRecord(records[0]) && + arms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return left; + } + return e; +} + /** A spread source the record desugar cannot enumerate: an untyped * (`unknown`) value, or one whose shape carries an index signature * (`Record`, `{ [key: string]: T }`) so its keys are * runtime state. Node copies own enumerable keys in insertion order, which * the checked-dynamic object reproduces. */ function dynSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean { - let e: ts.Expression = node; - while (ts.isParenthesizedExpression(e)) e = e.expression; + const e = spreadSourceNode(lowerer, node); if ((lowerer.typeOf(e).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true; const indexed = (t: IrType | null): boolean => { if (t?.kind === "record") return lowerer.shapes.get(t.shapeId)?.indexValue !== undefined; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json new file mode 100644 index 000000000..9d75cf30c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/spread-empty-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/spread-empty-default.ts b/tests/corpus/spread-empty-default.ts new file mode 100644 index 000000000..4905b9619 --- /dev/null +++ b/tests/corpus/spread-empty-default.ts @@ -0,0 +1,45 @@ +// `...(options ?? {})` and `...(options || {})` spread exactly what +// `...options` spreads: nullish and other falsy values contribute no keys. +// The optional source is an Omit<> view of an options interface whose +// fields include a function, so a checked-dynamic copy cannot hold it. +interface LoaderOptions { + cwd: string; + agentDir: string; + extraPaths?: string[]; + onLoad?: (paths: string[]) => string[]; +} +interface ServiceOptions { + cwd: string; + loaderOptions?: Omit; +} +class Loader { + readonly options: LoaderOptions; + constructor(options: LoaderOptions) { + this.options = options; + } + describe(): string { + const paths = this.options.onLoad?.(this.options.extraPaths ?? []) ?? []; + return `${this.options.cwd} ${this.options.agentDir} [${paths.join(",")}]`; + } +} +function viaNullish(options: ServiceOptions): Loader { + return new Loader({ ...(options.loaderOptions ?? {}), cwd: options.cwd, agentDir: "/agent" }); +} +function viaOr(options: ServiceOptions): Loader { + return new Loader({ cwd: options.cwd, agentDir: "/or", ...(options.loaderOptions || {}) }); +} +let evaluated = 0; +function counted(options: ServiceOptions): ServiceOptions["loaderOptions"] { + evaluated++; + return options.loaderOptions; +} +function viaCall(options: ServiceOptions): Loader { + return new Loader({ ...(counted(options) ?? {}), cwd: options.cwd, agentDir: "/call" }); +} +const bare: ServiceOptions = { cwd: "/w" }; +const full: ServiceOptions = { cwd: "/w", loaderOptions: { extraPaths: ["x"], onLoad: (p) => [...p, "y"] } }; +console.log(viaNullish(bare).describe()); +console.log(viaNullish(full).describe()); +console.log(viaOr(bare).describe()); +console.log(viaOr(full).describe()); +console.log(viaCall(bare).describe(), viaCall(full).describe(), evaluated); diff --git a/tests/harness/corpus-costs/spread-empty-default.ts.json b/tests/harness/corpus-costs/spread-empty-default.ts.json new file mode 100644 index 000000000..da02be042 --- /dev/null +++ b/tests/harness/corpus-costs/spread-empty-default.ts.json @@ -0,0 +1 @@ +{ "plain": 4589, "san": 7483 } From 312d7be2807dce1ba8352dd9301414dc948e78af Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:58 -0700 Subject: [PATCH 082/193] See Pick and Omit views of classes through type aliases A Pick or Omit view of a program class is held as a live class protocol, and generic methods called through it monomorphize against the class. Both checks read the view from the type's alias symbol, but a view named by a type alias (type View = Pick) carries the user's alias instead, so it mapped to a record of erased method fields that no instance converts to. classViewOf reads the alias declaration and sees the view through non-generic aliases, alias chains included. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/class-views.ts | 51 ++++++++++++ .../src/frontend/lowering/lower-calls.ts | 14 ++-- .../corpus/pick-alias-generic-methods.ts.json | 6 ++ .../pick-alias-view-modules/main.ts.json | 8 ++ tests/corpus/pick-alias-generic-methods.ts | 79 +++++++++++++++++++ tests/corpus/pick-alias-view-modules/ctx.ts | 2 + .../corpus/pick-alias-view-modules/globals.ts | 15 ++++ tests/corpus/pick-alias-view-modules/main.ts | 3 + .../pick-alias-view-modules/registry.ts | 21 +++++ .../pick-alias-generic-methods.ts.json | 1 + .../pick-alias-view-modules/main.ts.json | 1 + 11 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-views.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json create mode 100644 tests/corpus/pick-alias-generic-methods.ts create mode 100644 tests/corpus/pick-alias-view-modules/ctx.ts create mode 100644 tests/corpus/pick-alias-view-modules/globals.ts create mode 100644 tests/corpus/pick-alias-view-modules/main.ts create mode 100644 tests/corpus/pick-alias-view-modules/registry.ts create mode 100644 tests/harness/corpus-costs/pick-alias-generic-methods.ts.json create mode 100644 tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/class-views.ts b/packages/compiler/src/frontend/lowering/class-views.ts new file mode 100644 index 000000000..bacde86d3 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-views.ts @@ -0,0 +1,51 @@ +import * as ts from "../ts7/adapter.js"; + +/** A `Pick` / `Omit` view: the lib alias it instantiates and + * its source type argument `C`. */ +export interface ClassView { + name: "Pick" | "Omit"; + source: ts.Type; +} + +/** The lib's Pick or Omit alias (declared only in declaration files). */ +function libViewName(symbol: ts.Symbol): "Pick" | "Omit" | null { + const name = ts.symbolName(symbol); + if (name !== "Pick" && name !== "Omit") return null; + return symbol.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile) + ? name + : null; +} + +/** `type` as a Pick/Omit view, spelled inline (`Pick`) or through + * non-generic user aliases (`type View = Pick`, `type V2 = View`). + * An aliased instantiation carries the USER's alias symbol and no alias + * type arguments, so the alias declaration's type node names the view. + * Generic user aliases (`type V = Pick`) are not resolved. */ +export function classViewOf(checker: ts.TypeChecker, type: ts.Type, depth = 0): ClassView | null { + const alias = type.getAliasSymbol(); + if (alias === undefined) return null; + const direct = libViewName(alias); + if (direct !== null) { + const source = type.getAliasTypeArguments()[0]; + return source === undefined ? null : { name: direct, source }; + } + if (depth >= 8 || type.getAliasTypeArguments().length > 0) return null; + const decl = alias.declarations.find(ts.isTypeAliasDeclaration); + if (decl === undefined || (decl.typeParameters?.nodes.length ?? 0) > 0) return null; + let node: ts.TypeNode = decl.type; + while (ts.isParenthesizedTypeNode(node)) node = node.type; + if (!ts.isTypeReferenceNode(node)) return null; + let target = checker.getSymbolAtLocation(node.typeName); + if (target !== undefined && (target.flags & ts.SymbolFlags.Alias) !== 0) + target = checker.getAliasedSymbol(target); + if (target === undefined) return null; + const viewName = libViewName(target); + if (viewName !== null) { + const argument = node.typeArguments?.nodes[0]; + return argument === undefined + ? null + : { name: viewName, source: checker.getTypeFromTypeNode(argument) }; + } + if ((target.flags & ts.SymbolFlags.TypeAlias) === 0) return null; + return classViewOf(checker, checker.getDeclaredTypeOfSymbol(target), depth + 1); +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index fcdce59f0..32de3731e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4,6 +4,7 @@ import { lowerArrayClassStaticCall, } from "./builtins/array-classes.js"; import { structuralCallInstance } from "./structural-instances.js"; +import { classViewOf } from "./class-views.js"; import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js"; import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; @@ -11365,7 +11366,8 @@ function maybePromiseHandlerResult( return stmt; } -/** `view.m(args)` where `view` is a `Pick` / `Omit` view of a +/** `view.m(args)` where `view` is a `Pick` / `Omit` view (inline + * or through type aliases, classViewOf) of a * program class C and `m` is one of C's GENERIC methods (its overloads * included): generic methods have no dispatchable method value, so the * view's live receiver is extracted as the C instance it holds (a checked @@ -11378,11 +11380,9 @@ function pickViewGenericMethodCall( access: ts.PropertyAccessExpression, recvTs: ts.Type, ): IrExpr | null { - const alias = recvTs.getAliasSymbol(); - if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit")) - return null; - if (!alias.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)) return null; - const source = recvTs.getAliasTypeArguments()[0]; + const view = classViewOf(lowerer.checker, recvTs); + if (view === null) return null; + const source = view.source; const symbol = source?.getSymbol(); const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined; if (!info || call.questionDotToken || access.questionDotToken) return null; @@ -11402,7 +11402,7 @@ function pickViewGenericMethodCall( lowerer.unsupported( "SC1090", access, - `calling the generic method '${access.name.text}' through a '${ts.symbolName(alias)}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`, + `calling the generic method '${access.name.text}' through a '${view.name}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`, ); } const receiver: IrExpr = diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json new file mode 100644 index 000000000..8927822a9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/pick-alias-generic-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json new file mode 100644 index 000000000..1fd543dbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/pick-alias-view-modules/registry.ts", + "/tests/corpus/pick-alias-view-modules/globals.ts", + "/tests/corpus/pick-alias-view-modules/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/pick-alias-generic-methods.ts b/tests/corpus/pick-alias-generic-methods.ts new file mode 100644 index 000000000..a15d25579 --- /dev/null +++ b/tests/corpus/pick-alias-generic-methods.ts @@ -0,0 +1,79 @@ +// Generic class methods called through a Pick<>/Omit<> view of the class +// that is NAMED by a type alias (`type View = Pick`), also +// through an alias of that alias. Inline Pick<> views already compile; the +// alias hides the Pick from the view's type, which then mapped to a +// record of erased method fields that a Registry instance cannot convert to. +interface ChatModel { + kind: "chat"; + id: string; +} +interface ImageModel { + kind: "image"; + id: string; + size: number; +} +interface ModelMap { + chat: ChatModel; + image: ImageModel; +} +type ModelKind = keyof ModelMap; + +class Registry { + private readonly all: (ChatModel | ImageModel)[] = [ + { kind: "chat", id: "c1" }, + { kind: "image", id: "i1", size: 512 }, + { kind: "chat", id: "c2" }, + ]; + modelsOfKind(kind: K, provider?: string): readonly ModelMap[K][] { + return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][]; + } + modelOfKind(kind: K, id: string): ModelMap[K] | undefined { + for (const m of this.modelsOfKind(kind)) if (m.id === id) return m; + return undefined; + } + async availableOfKind(kind: K, options?: { tag?: string }): Promise { + return this.modelsOfKind(kind).filter(() => options?.tag !== "none"); + } + label(): string { + return "registry"; + } +} + +type RegistryView = Pick; +type ReadOnlyView = RegistryView; +type NoAsyncView = Omit; + +function toKind(value: unknown): ModelKind { + return value === "image" ? "image" : "chat"; +} + +function describe(models: RegistryView, args: unknown[]): string { + const [kind, id] = args; + const list = models.modelsOfKind(toKind(kind)).map((m) => m.id); + const one = models.modelOfKind(toKind(kind), String(id)); + return `${models.label()} ${list.join(",")} ${one === undefined ? "none" : one.id}`; +} + +function globals(models: ReadOnlyView): Record unknown> { + return { + list: (args) => models.modelsOfKind(toKind(args[0]), args[1] === undefined ? undefined : String(args[1])).length, + size: (args) => models.modelOfKind("image", String(args[0]))?.size ?? -1, + }; +} + +function sizes(models: NoAsyncView): string { + return models.modelsOfKind("image").map((m) => `${m.id}:${m.size}`).join(","); +} + +async function main(): Promise { + const registry = new Registry(); + console.log(describe(registry, ["chat", "c2"])); + console.log(describe(registry, ["image", "zz"])); + const g = globals(registry); + console.log(g.list!(["chat"]), g.list!(["chat", "none"]), g.size!(["i1"]), g.size!(["c1"])); + console.log(sizes(registry)); + const view: RegistryView = registry; + const available = await view.availableOfKind("image", { tag: "x" }); + console.log(available.map((m) => `${m.id}:${m.size}`).join(","), (await view.availableOfKind("chat", { tag: "none" })).length); +} +main(); diff --git a/tests/corpus/pick-alias-view-modules/ctx.ts b/tests/corpus/pick-alias-view-modules/ctx.ts new file mode 100644 index 000000000..57bff4a07 --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/ctx.ts @@ -0,0 +1,2 @@ +import type { Registry } from "./registry.ts"; +export interface Ctx { registry: Registry; label: string } diff --git a/tests/corpus/pick-alias-view-modules/globals.ts b/tests/corpus/pick-alias-view-modules/globals.ts new file mode 100644 index 000000000..7d0f7f07c --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/globals.ts @@ -0,0 +1,15 @@ +import type { Ctx } from "./ctx.ts"; +import type { ModelKind, RegistryView } from "./registry.ts"; +function toKind(value: unknown): ModelKind { + return value === "image" ? "image" : "chat"; +} +function globals(models: RegistryView): (args: unknown[]) => string { + return (args) => { + const [kind] = args; + return models.modelsOfKind(toKind(kind)).map((m) => m.id).join(","); + }; +} +export function run(ctx: Ctx | undefined): string { + const list = ctx ? globals(ctx.registry) : undefined; + return list ? `${ctx?.label} ${list(["chat"])} ${list(["image"])}` : "none"; +} diff --git a/tests/corpus/pick-alias-view-modules/main.ts b/tests/corpus/pick-alias-view-modules/main.ts new file mode 100644 index 000000000..86c15c49c --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/main.ts @@ -0,0 +1,3 @@ +import { Registry } from "./registry.ts"; +import { run } from "./globals.ts"; +console.log(run({ registry: new Registry(), label: "L" }), run(undefined)); diff --git a/tests/corpus/pick-alias-view-modules/registry.ts b/tests/corpus/pick-alias-view-modules/registry.ts new file mode 100644 index 000000000..5cdf4bd4b --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/registry.ts @@ -0,0 +1,21 @@ +export interface ChatModel { kind: "chat"; id: string } +export interface ImageModel { kind: "image"; id: string; size: number } +export interface ModelMap { chat: ChatModel; image: ImageModel } +export type ModelKind = keyof ModelMap; +export class Registry { + private readonly all: (ChatModel | ImageModel)[] = [ + { kind: "chat", id: "c1" }, + { kind: "image", id: "i1", size: 512 }, + ]; + modelsOfKind(kind: K, provider?: string): readonly ModelMap[K][] { + return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][]; + } + modelOfKind(kind: K, id: string): ModelMap[K] | undefined { + for (const m of this.modelsOfKind(kind)) if (m.id === id) return m; + return undefined; + } + label(): string { + return "registry"; + } +} +export type RegistryView = Pick; diff --git a/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json b/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json new file mode 100644 index 000000000..3d92741c6 --- /dev/null +++ b/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 7191, "san": 12536 } diff --git a/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json b/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json new file mode 100644 index 000000000..296b09a53 --- /dev/null +++ b/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json @@ -0,0 +1 @@ +{ "plain": 5283, "san": 8250 } From acb0a4c9ed4c3250d6ca8d15ce41e679be1271d3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:30:15 -0700 Subject: [PATCH 083/193] Adapt method fields across instantiations with checked conversions A record width copy such as Provider<"beta"> into Provider failed on method fields whose parameter record depends on the instantiation: TypeScript admits them by method bivariance, but the adapter only took mechanical parameter conversions. Inside a record's field plan, such a field now takes a checked adapter: parameters convert with the record width plans, and union arms without a home throw the catchable TypeError. Top-level function conversions and every pair that planned before are unchanged. The safe direction lifts too: record-width's method field whose parameter record widens now plans, so its SC2002 refusal gives way to the later honest SC1090 (a function value has no inspect lowering). Co-Authored-By: Claude Opus 5.5 --- .../lowering/coercions/function-adapters.ts | 13 ++- .../src/frontend/lowering/coercions/state.ts | 7 ++ .../lowering/coercions/structural-plans.ts | 57 +++++++++++- .../frontend/lowering/coercions/structural.ts | 43 +++++++-- .../src/frontend/lowering/width-lift.ts | 7 ++ ...ethod-field-instantiation-adapters.test.ts | 87 +++++++++++++++++++ ...ethod-field-instantiation-adapters.ts.json | 6 ++ .../method-field-instantiation-adapters.ts | 51 +++++++++++ tests/diagnostics/record-width.ts | 6 +- .../harness/__snapshots__/record-width.ts.txt | 9 -- ...ethod-field-instantiation-adapters.ts.json | 1 + 11 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/method-field-instantiation-adapters.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json create mode 100644 tests/corpus/method-field-instantiation-adapters.ts create mode 100644 tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 78c273b66..03c7f4259 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -126,11 +126,20 @@ export function cleanFuncAdaptable( if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; if (src.params.length > dst.params.length) return false; + // Checked mode (a method field's adapter, WidthLift funcAdaptChecked): + // parameters and results may also take the record width conversions, + // checked retags included. + const checked = lowerer.coercions.checkedRetags > 0; for (let i = 0; i < src.params.length; i++) { - if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; + if ( + !lowerer.coercibleValue(dst.params[i]!, src.params[i]!) && + !(checked && lowerer.widthLiftPlan(dst.params[i]!, src.params[i]!) !== null) + ) + return false; } if (dst.ret.kind === "void") return true; if (lowerer.coercibleValue(src.ret, dst.ret)) return true; + if (checked && lowerer.widthLiftPlan(src.ret, dst.ret) !== null) return true; return src.ret.kind === "void" && dst.ret.kind === "dyn"; } @@ -390,7 +399,7 @@ export function funcCoerceAdapter( strandRet = false; // A record result the slot's result is a structural width of. if (strandRet && lowerer.widthLiftPlan(fromT.ret, toT.ret)) strandRet = false; - const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`; + const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}${lowerer.coercions.checkedRetags > 0 ? ":checked" : ""}`; const existing = lowerer.coercions.retags.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%fn.adapt", key); diff --git a/packages/compiler/src/frontend/lowering/coercions/state.ts b/packages/compiler/src/frontend/lowering/coercions/state.ts index 9fa815081..1760df174 100644 --- a/packages/compiler/src/frontend/lowering/coercions/state.ts +++ b/packages/compiler/src/frontend/lowering/coercions/state.ts @@ -9,6 +9,13 @@ export class CoercionState { // plan checks its other fields. Remove every assumption on unwind; // generated helpers must intern their names before building their bodies. readonly planning = new Set(); + /** > 0 while a record width plan plans or builds its fields: method + * fields may take checked function adapters (WidthLift funcAdaptChecked). */ + methodFieldDepth = 0; + /** > 0 while a checked function adapter plans or builds: contravariant + * parameters may width-lift, and union fields may retag with trapping + * arms (WidthLift checkedRetag). */ + checkedRetags = 0; // Registry revisions invalidate only fully validated copy-only routes. readonly copyRetags = new Map(); // Provenance for checked extraction; consumers must not infer it from names. diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 2a1d75331..721f027c2 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -1,4 +1,4 @@ -import { planRecordUnionLift, planRecordUnionWrap } from "../union-retag.js"; +import { planRecordUnionLift, planRecordUnionWrap, planUnionRetag } from "../union-retag.js"; import type { WidthLift } from "../width-lift.js"; import type { IrRecordShape, IrType } from "../../../ir/ir.js"; import { canDynCheckTo, isUnitType, typeEquals, UNDEFINED_T } from "../../../ir/ir.js"; @@ -57,6 +57,33 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width lowerer.armTag(dst.unionId, UNDEFINED_T) >= 0 ) return { how: "unitRetag" }; + // Inside a checked method adapter (contravariant positions): source + // arms with no destination home trap instead of refusing the pair. + if (lowerer.coercions.checkedRetags > 0) { + const from = lowerer.unions.get(src.unionId); + const to = lowerer.unions.get(dst.unionId); + if (from && to) { + const trappable: number[] = []; + from.arms.forEach((arm, tag) => { + if (isUnitType(arm) || lowerer.armTag(dst.unionId, arm) >= 0) return; + if (to.arms.some((d) => !isUnitType(d) && lowerer.widthLiftPlan(arm, d) !== null)) + return; + trappable.push(tag); + }); + if ( + trappable.length > 0 && + trappable.length < from.arms.length && + planUnionRetag( + from, + to, + (id) => lowerer.shapes.get(id), + (s, d) => lowerer.widthLiftPlan(s, d), + new Set(trappable), + ) !== null + ) + return { how: "checkedRetag", trappable }; + } + } return null; } // A unit-typed source can't wrap here (unionWrap requires the @@ -71,7 +98,7 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width return { how: "discriminantWrap" }; return { how: "wrap", tag }; } - const candidates: { tag: number; arm: IrType }[] = []; + const candidates: { tag: number; arm: IrType; checked: boolean }[] = []; def.arms.forEach((arm, i) => { if (isUnitType(arm)) return; const sameFamily = @@ -87,9 +114,15 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width arm.kind === "array") || (src.kind === "object" && arm.kind === "record") || (src.kind === "record" && arm.kind === "object"); - if (sameFamily && lowerer.widthLiftPlan(src, arm) !== null) candidates.push({ tag: i, arm }); + const armLift = sameFamily ? lowerer.widthLiftPlan(src, arm) : null; + if (armLift !== null) + candidates.push({ tag: i, arm, checked: armLift.how === "funcAdaptChecked" }); }); let selected = candidates; + // A clean function candidate always wins over a checked one, so every + // selection that resolves today resolves the same way. + if (selected.some((c) => c.checked) && selected.some((c) => !c.checked)) + selected = selected.filter((c) => !c.checked); if (selected.length > 1 && src.kind === "record") { const source = lowerer.shapes.get(src.shapeId); // An inferred JS union may widen its tag while retaining variants @@ -216,6 +249,22 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width if (dst.kind === "func" && src.kind === "func" && lowerer.cleanFuncAdaptable(src, dst)) { return { how: "funcAdapt" }; } + // A method FIELD of a record width copy whose parameters convert only + // through a checked narrowing (method bivariance across instantiations): + // a checked adapter. Top-level function conversions never see it. + if ( + dst.kind === "func" && + src.kind === "func" && + lowerer.coercions.methodFieldDepth > 0 && + lowerer.coercions.checkedRetags === 0 + ) { + lowerer.coercions.checkedRetags++; + try { + if (lowerer.cleanFuncAdaptable(src, dst)) return { how: "funcAdaptChecked" }; + } finally { + lowerer.coercions.checkedRetags--; + } + } if (dst.kind === "record" && src.kind === "record") { // An INDEX-SIGNATURE destination takes the overflow CAPTURE reshape: // declared fields copy off the source, every other source field keys @@ -370,6 +419,7 @@ export function recordWidthPlan( // fields answers "assume coercible" — see CoercionState.planning. if (lowerer.coercions.planning.has(key)) return new Map(); lowerer.coercions.planning.add(key); + lowerer.coercions.methodFieldDepth++; try { const plan = new Map(); for (const tf of to.fields) { @@ -405,6 +455,7 @@ export function recordWidthPlan( } return plan; } finally { + lowerer.coercions.methodFieldDepth--; lowerer.coercions.planning.delete(key); } } diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index a1d3283b3..7cdcdd8aa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -131,6 +131,34 @@ export function applyWidthLift( throw new InternalCompilerError("lowerer bug: planned retag lift failed to intern"); return { kind: "call", callee: retag, args: [value], type: dst, loc }; } + case "checkedRetag": { + if (dst.kind !== "union" || value.type.kind !== "union") + throw new InternalCompilerError("lowerer bug: checked retag lift shape"); + const retag = lowerer.unionRetagHelper( + value.type.unionId, + dst.unionId, + loc, + new Set(lift.trappable), + ); + if (!retag) + throw new InternalCompilerError("lowerer bug: planned checked retag lift failed to intern"); + return { kind: "call", callee: retag, args: [value], type: dst, loc }; + } + case "funcAdaptChecked": { + if (dst.kind !== "func" || value.type.kind !== "func") + throw new InternalCompilerError("lowerer bug: checked funcAdapt lift shape"); + lowerer.coercions.checkedRetags++; + try { + const adapter = lowerer.funcCoerceAdapter(value.type, dst, loc); + if (!adapter) + throw new InternalCompilerError( + "lowerer bug: planned checked funcAdapt lift failed to intern", + ); + return { kind: "call", callee: adapter, args: [value], type: dst, loc }; + } finally { + lowerer.coercions.checkedRetags--; + } + } case "unitRetag": { if (dst.kind !== "union" || value.type.kind !== "union") throw new InternalCompilerError("lowerer bug: unit retag lift shape"); @@ -527,11 +555,16 @@ export function recordWidthHelper( lowerer.coercionOriginNode = null; lowerer.coercionFieldOrigin = null; try { - lowerer.liftedFns.push( - buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) => - declared === null ? null : fieldOrigin(lowerer.checker, declared, field), - ), - ); + lowerer.coercions.methodFieldDepth++; + try { + lowerer.liftedFns.push( + buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) => + declared === null ? null : fieldOrigin(lowerer.checker, declared, field), + ), + ); + } finally { + lowerer.coercions.methodFieldDepth--; + } } finally { lowerer.coercionOriginNode = outerNode; lowerer.coercionFieldOrigin = outerField; diff --git a/packages/compiler/src/frontend/lowering/width-lift.ts b/packages/compiler/src/frontend/lowering/width-lift.ts index 59209dc50..1e2c75c7e 100644 --- a/packages/compiler/src/frontend/lowering/width-lift.ts +++ b/packages/compiler/src/frontend/lowering/width-lift.ts @@ -42,6 +42,13 @@ export type WidthLift = | { how: "dynOut" } | { how: "upcast" } | { how: "funcAdapt" } + /** A method field whose parameter records differ per instantiation + * (`Provider<"x">` into `Provider`, admitted by method bivariance): + * funcCoerceAdapter built in checked mode. Record-field planning only. */ + | { how: "funcAdaptChecked" } + /** A union into a union where the `trappable` source arms have no home: + * they throw the catchable TypeError. Checked mode only. */ + | { how: "checkedRetag"; trappable: number[] } /** A promise whose payload widens (`Promise` into `Promise`): * the checked payload widening coerceToExpected applies at a promise slot, * which keeps the promise's identity and settlement turn. */ diff --git a/packages/compiler/test/method-field-instantiation-adapters.test.ts b/packages/compiler/test/method-field-instantiation-adapters.test.ts new file mode 100644 index 000000000..189901675 --- /dev/null +++ b/packages/compiler/test/method-field-instantiation-adapters.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +// A Provider<"beta"> held as Provider receives an alpha model through +// method bivariance: Model<"beta">'s compat holds only undefined, so the +// alpha model's compat object cannot convert. Native throws the checked +// conversion's TypeError (types don't lie); Node prints `beta cancel a1 lie` +// and `passed through` instead, because its method call never checks the +// argument's shape. That divergence is the documented checked-conversion +// policy, so this case is a native package test rather than a differential +// corpus program. +const program = `// A Provider<"beta"> held as Provider receives an alpha model through +// method bivariance: Model<"beta">'s compat holds only undefined, so the +// alpha model's compat object cannot convert. Native throws the checked +// conversion's TypeError (types don't lie); Node passes the lie through. +type Api = "alpha" | "beta"; +interface AlphaCompat { strict?: boolean } +interface Model { + id: string; + api: TApi; + compat?: TApi extends "alpha" ? AlphaCompat : never; +} +interface Provider { + readonly id: string; + cancel?(model: Model, handle: string): Promise; +} +function betaProvider(): Provider<"beta"> { + return { + id: "b", + cancel: async (model, handle) => { + console.log("beta cancel", model.id, handle); + }, + }; +} +async function main(): Promise { + const providers: Provider[] = [betaProvider()]; + const beta = providers[0]!; + await beta.cancel?.({ id: "b1", api: "beta" }, "ok"); + try { + await beta.cancel?.({ id: "a1", api: "alpha", compat: { strict: true } }, "lie"); + console.log("passed through"); + } catch (error) { + console.log(error instanceof TypeError, (error as Error).message); + } +} +main(); +`; + +test.each(["llvm"] as const)( + "checked method-field adapters throw on an instantiation mismatch (%s)", + async (backend) => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-method-field-adapters-")); + try { + const entry = join(dir, "main.ts"); + writeFileSync(entry, program); + const result = await compile(entry, { + backend, + sanitize, + outDir: dir, + outPath: join(dir, "program"), + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const child = spawnSync(result.binaryPath, { encoding: "utf8" }); + const stderr = + sanitize && process.platform === "linux" + ? child.stderr.replace( + /^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm, + "", + ) + : child.stderr; + expect(stderr).toBe(""); + expect(child.status).toBe(0); + expect(child.stdout).toBe( + "beta cancel b1 ok\ntrue a '{ strict: boolean | undefined }' value is not representable in the target union (a value narrowed or asserted past it still held it)\n", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json new file mode 100644 index 000000000..095817660 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/method-field-instantiation-adapters.ts" + ], + "diags": [] +} diff --git a/tests/corpus/method-field-instantiation-adapters.ts b/tests/corpus/method-field-instantiation-adapters.ts new file mode 100644 index 000000000..5fda08000 --- /dev/null +++ b/tests/corpus/method-field-instantiation-adapters.ts @@ -0,0 +1,51 @@ +// Provider<"alpha"> and Provider<"beta"> into Provider: method fields +// whose parameter is the instantiation-dependent Model (a +// conditional compat member that is `never` for beta) convert through a +// checked adapter. TypeScript admits the pair by method bivariance; every +// call here passes each provider its own models, so nothing traps. +type Api = "alpha" | "beta"; +interface AlphaCompat { + strict?: boolean; +} +interface Model { + id: string; + api: TApi; + compat?: TApi extends "alpha" ? AlphaCompat : never; +} +interface Provider { + readonly id: string; + models(): Model[]; + cancel?(model: Model, handle: string): Promise; +} +function alphaProvider(): Provider<"alpha"> { + return { + id: "a", + models: () => [{ id: "a1", api: "alpha", compat: { strict: true } }], + cancel: async (model, handle) => { + console.log("cancel", model.id, handle, String(model.compat?.strict)); + }, + }; +} +function betaProvider(): Provider<"beta"> { + return { + id: "b", + models: () => [{ id: "b1", api: "beta" }], + cancel: async (model, handle) => { + console.log("beta cancel", model.id, handle, model.compat === undefined); + }, + }; +} +function plainProvider(): Provider<"beta"> { + return { id: "p", models: () => [] }; +} +function all(): Provider[] { + return [alphaProvider(), betaProvider(), plainProvider()]; +} +async function main(): Promise { + for (const p of all()) { + const [m] = p.models(); + console.log(p.id, m?.id, m?.api, p.cancel === undefined); + if (m) await p.cancel?.(m, "h"); + } +} +main(); diff --git a/tests/diagnostics/record-width.ts b/tests/diagnostics/record-width.ts index c02b4e294..f42decd88 100644 --- a/tests/diagnostics/record-width.ts +++ b/tests/diagnostics/record-width.ts @@ -12,9 +12,9 @@ const loose: Loose = { a: 1 }; const picked: PickedA = loose; console.log(picked); -// A method field whose parameter shapes relate only BIVARIANTLY: neither -// parameter type converts into the other mechanically, so the field does -// not lift. +// A method field whose parameter shapes relate only BIVARIANTLY: the field +// lifts as a checked adapter, so only the console.log below refuses (a +// function value has no inspect lowering). type SrcM = { m(x: { a: number }): void; extra: number }; type DstM = { m(x: { a: number; b: number }): void }; const srcM: SrcM = { diff --git a/tests/harness/__snapshots__/record-width.ts.txt b/tests/harness/__snapshots__/record-width.ts.txt index 37be4df54..4c8744a3c 100644 --- a/tests/harness/__snapshots__/record-width.ts.txt +++ b/tests/harness/__snapshots__/record-width.ts.txt @@ -7,15 +7,6 @@ record-width.ts:12:25 - error SC2002: record shapes must match exactly or width- hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead -record-width.ts:26:20 - error SC2002: record shapes must match exactly or width-coerce: expected '{ m: ({ a: number; b: number }) => void }', got '{ extra: number; m: ({ a: number }) => void }' — field 'm': '({ a: number }) => void' does not lift into '({ a: number; b: number }) => void' - - 25 | }; - 26 | const dstM: DstM = srcM; - | ^~~~ - 27 | console.log(dstM); - - hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead - record-width.ts:27:13 - error SC1090: console.log of '{ m: ({ a: number; b: number }) => void }' values (function values have no inspect lowering (no runtime name exists — Node prints '[Function: name]'; pass the function's declared identifier directly to util.inspect for the baked form)) is not supported yet 26 | const dstM: DstM = srcM; diff --git a/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json b/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json new file mode 100644 index 000000000..ae4c04177 --- /dev/null +++ b/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json @@ -0,0 +1 @@ +{ "plain": 6706, "san": 112967 } From 53f5efadb591fda49f3674f8da1db3ae34357e5f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:15:21 -0700 Subject: [PATCH 084/193] Copy a record's kept source keys through the index-signature capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record built from the checked-dynamic tree keeps the own keys its shape does not declare, and every JS copy carries them. `%rec.capture` (the closed-to-index-signature width reshape) rebuilt the target from the source's DECLARED fields only, so those keys vanished with no diagnostic. The shape that made this visible is an inline, unannotated async arrow in a generic call: interface T { transformHeaders?(h: H): H | Promise } async function f(o: O | undefined) { … } f({ transformHeaders: async (h) => ({ ...h, x: "1" }) }) No contextual type reaches the arrow through the type parameter, so its return slot is contextualized by tsc's own inference, which drops the spread's index signature: the closure is typed Promise<{ x }> and its body lowers through the checked-dynamic spread into that closed record — the keys survive in the record's kept source view. The call site reads the member through the declared H and adapts the function value, and that ovf capture dropped `accept`. Node prints "accept,x". When the source kept such a view, rebuild the target from that live view instead of the static field list: the same validated keyed conversion the checked record builder performs, which copies exactly the keys Node copies. Everything else keeps the existing field-by-field copy. A program whose functions never convert a checked-dynamic value into a closed record cannot keep such a view anywhere, so the capture asks the program too: one lazy scan over every lowered function settles whether a kept source is possible at all, and only then does the helper finalize its body with the branch. The scan looks through union arms, promise values, and array elements; a union reached while it is already being visited contributes nothing until the outer union settles, and an answer that depended on such a cycle is not memoized, because another entry into the cycle may reach the arm it could not see. Programs without such a conversion keep the previous helper byte for byte and link no checked-dynamic conversion code. tests/corpus/record-source-keys-index-capture.ts pins the drop both ways: the inline block-bodied async arrow (assigned through the await and read into a local first) and the async method form print the caller keys on the base compiler and match Node byte for byte with this change, while the non-generic, expression-bodied, nested-field and sync-arrow forms are controls that already matched on both. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/indexed-objects.ts | 45 +++++- .../compiler/src/frontend/lowering/lowerer.ts | 22 +++ .../src/frontend/lowering/record-sources.ts | 71 +++++++++- .../record-source-keys-index-capture.ts.json | 6 + .../record-source-keys-index-capture.ts | 130 ++++++++++++++++++ .../record-source-keys-index-capture.ts.json | 1 + 6 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json create mode 100644 tests/corpus/record-source-keys-index-capture.ts create mode 100644 tests/harness/corpus-costs/record-source-keys-index-capture.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index 92a724b6b..655812dc3 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -21,12 +21,14 @@ import { type SrcLoc, UNDEFINED_T, arrayOf, + canDynCheckTo, isRefCounted, isSupportedIndexValue, isUnitType, typeEquals, } from "../../../ir/ir.js"; import { tryLowerExpression } from "../expressions/try-lower-expression.js"; +import { recordMayKeepSource, recordSourceTest } from "../record-sources.js"; import { locOf } from "../../program.js"; import { typeKey } from "../../type-mapper.js"; import { type WidthLift } from "../lowerer.js"; @@ -1213,8 +1215,47 @@ export function lowerRecordOvfCaptureHelper( ), ); } + // A CLOSED source can still hold own keys its shape does not declare: a + // record built from the checked-dynamic tree keeps them beside it (the + // record-sources.ts view), and every JS copy — spread, Object.assign, + // JSON — carries them. The field-by-field rebuild above cannot see + // them, so when the source kept such a view, rebuild the target from + // that live view instead: the validated keyed conversion the checked + // record builder performs, which copies exactly the keys Node copies. + // The declared-field copies before this branch are pure writes into a + // fresh local, so the redone work is only wasted slot stores on the + // rare path where the view exists. + // The conversion is only honest when the checked-dynamic tree can check it, + // and the view can only exist if the program converts something into a + // closed record at all. Both facts are settled before the shape helpers + // finalize their bodies, so the tail is chosen there. + const sourcedFromView = + fIv === null && + recordMayKeepSource(lowerer, fromT) && + canDynCheckTo( + toT, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ); body.push({ kind: "return", value: outRef, loc }); const suffix = body.slice(1 + fieldStmts.size); + const keptSourceTail = (): IrStmt[] => { + const fromView: IrExpr = { + kind: "dynCheck", + value: { kind: "dynFrom", value: sRef, type: DYN, loc }, + type: toT, + loc, + }; + const fromViewReturn: IrStmt = { kind: "return", value: fromView, loc }; + const fromViewBranch: IrStmt = { + kind: "if", + cond: recordSourceTest(sRef, loc), + then: [fromViewReturn], + else_: null, + loc, + }; + return [fromViewBranch, ...suffix]; + }; const fn: IrFunction = { name, params: [{ localId: "s.0", name: "s", type: fromT }], @@ -1236,13 +1277,15 @@ export function lowerRecordOvfCaptureHelper( lowerer.shapeOrderHelperFinalizers.push(() => { const current = lowerer.shapes.get(fromId) ?? from; const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); + let tail: IrStmt[] = suffix; + if (sourcedFromView && lowerer.recordSourcesPossible) tail = keptSourceTail(); fn.body = [ body[0]!, ...currentOrder.flatMap((field) => { const stmt = fieldStmts.get(field); return stmt ? [stmt] : []; }), - ...suffix, + ...tail, ]; }); lowerer.liftedFns.push(fn); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6ae1ea9f5..7057f45c3 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -93,6 +93,7 @@ import { import { identityPreservingWidening } from "./coercions/identity.js"; import { scanDivergences } from "./divergences.js"; import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js"; +import { functionKeepsRecordSources } from "./record-sources.js"; import { exprFunctionReference } from "../../ir/function-references.js"; import { everyStmtList, observeRecordLiteralRebuilds } from "../../ir/traverse.js"; import { checkedClassAssertion } from "./class-assertions.js"; @@ -3303,6 +3304,11 @@ export class Lowerer { * Reachability lowers inits before the declarations they discover, so * these rebuild after the worklist restores historical shape metadata. */ readonly shapeOrderHelperFinalizers: (() => void)[] = []; + /** Whether any lowered function converts a checked-dynamic value into a + * closed record, the only conversion that can leave a record holding + * undeclared source keys (record-sources.ts). Settled once lowering ends, + * before the shape helpers finalize their bodies. */ + recordSourcesPossible = false; /** Construction witnesses that choose each shape's enumeration order * once lowering settles (key-order-witness.ts). */ readonly keyOrder = new KeyOrderNotes(); @@ -10427,6 +10433,22 @@ export class Lowerer { ...this.implicitFns, ]); observeRecordLiteralRebuilds(null); + // A program whose functions never convert a checked-dynamic value into a + // closed record cannot keep a source view anywhere, so the captures that + // consult one keep their static copy (record-sources.ts). + const allFunctions = [ + ...orderedUnits, + ...initFunctions, + ...instanceFunctions, + ...this.liftedFns, + ...this.implicitFns, + ]; + const sourceScan = { memo: new Map(), visiting: new Set() }; + for (const fn of allFunctions) { + if (!functionKeepsRecordSources(this, fn, sourceScan)) continue; + this.recordSourcesPossible = true; + break; + } for (const finalize of this.shapeOrderHelperFinalizers) finalize(); this.settleSharedHelperLocations([ ...orderedUnits, diff --git a/packages/compiler/src/frontend/lowering/record-sources.ts b/packages/compiler/src/frontend/lowering/record-sources.ts index 57ac9a6b0..1e7ce254d 100644 --- a/packages/compiler/src/frontend/lowering/record-sources.ts +++ b/packages/compiler/src/frontend/lowering/record-sources.ts @@ -8,9 +8,12 @@ * serialization, untyped views, inspect, Object.keys/values/entries — * answer through the record's live checked view instead of its static * field list. This module names the record types that can carry such a - * source and the runtime test the surfaces branch on. */ -import type { IrExpr, IrType, SrcLoc } from "../../ir/ir.js"; + * source, the runtime test the surfaces branch on, and whether the program + * converts anything into one at all (a program without such a conversion + * answers the test false everywhere, so its captures keep static copies). */ +import type { IrExpr, IrFunction, IrType, SrcLoc } from "../../ir/ir.js"; import { BOOL, DYN, recordTextCodecClass, shapeHasAccessorSlots } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; import { varRef } from "../../ir/build.js"; import type { Lowerer } from "./lowerer.js"; import { isPerfEntryType } from "./builtins/performance.js"; @@ -37,6 +40,70 @@ export function recordSourceTest(value: IrExpr, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "record.sourceHas", args: [value], type: BOOL, loc }; } +/** A union revisited while it is being visited answers as if its cycle arm + * were empty (`keeps: false`), so the caller's answer is incomplete until the + * outer union settles. `cycled` marks that incompleteness: such an answer is + * never memoized, because another entry point into the same cycle could reach + * the missing arm. */ +interface KeepsSourceAnswer { + keeps: boolean; + cycled: boolean; +} + +/** Shared work list of the whole-program scan: settled union answers, and the + * unions on the current path. */ +export interface KeepsSourceScan { + memo: Map; + visiting: Set; +} + +/** Whether a checked conversion into `target` can leave a record holding + * undeclared source keys. The conversion reads one record at a time, so the + * records nested in a union arm, a promise, or an array count too. */ +function conversionKeepsSource( + lowerer: Lowerer, + target: IrType, + scan: KeepsSourceScan, +): KeepsSourceAnswer { + if (target.kind === "record") + return { keeps: recordMayKeepSource(lowerer, target), cycled: false }; + if (target.kind === "promise") return conversionKeepsSource(lowerer, target.inner, scan); + if (target.kind === "array") return conversionKeepsSource(lowerer, target.elem, scan); + if (target.kind !== "union") return { keeps: false, cycled: false }; + const known = scan.memo.get(target.unionId); + if (known !== undefined) return { keeps: known, cycled: false }; + if (scan.visiting.has(target.unionId)) return { keeps: false, cycled: true }; + const union = lowerer.unions.get(target.unionId); + if (union === undefined) return { keeps: false, cycled: false }; + scan.visiting.add(target.unionId); + let keeps = false; + let cycled = false; + for (const arm of union.arms) { + const answer = conversionKeepsSource(lowerer, arm, scan); + keeps = keeps || answer.keeps; + cycled = cycled || answer.cycled; + } + scan.visiting.delete(target.unionId); + if (!cycled) scan.memo.set(target.unionId, keeps); + return { keeps, cycled }; +} + +/** Whether `fn` holds a validated conversion that can note a record source: + * a `dynCheck` whose target accepts `recordMayKeepSource`. Only such a + * conversion creates the kept view `recordSourceTest` answers, so a program + * without one never needs a capture to consult the view. */ +export function functionKeepsRecordSources( + lowerer: Lowerer, + fn: IrFunction, + scan: KeepsSourceScan, +): boolean { + return !everyStmtList(fn.body, { + expr: (expr) => + expr.kind !== "dynCheck" || !conversionKeepsSource(lowerer, expr.type, scan).keeps, + stmt: () => true, + }); +} + /** A reflective answer over a record value that branches on its kept * source: `staticAnswer` walks the declared fields, `viewAnswer` answers * from the live checked view (typed `type`, the static answer's type). The diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json new file mode 100644 index 000000000..17cabc69b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-capture.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-source-keys-index-capture.ts b/tests/corpus/record-source-keys-index-capture.ts new file mode 100644 index 000000000..6c45ce872 --- /dev/null +++ b/tests/corpus/record-source-keys-index-capture.ts @@ -0,0 +1,130 @@ +// A record built from the checked-dynamic tree keeps the own keys its shape +// does not declare, and every JS copy carries them. Converting such a record +// into an index-signature record must copy them too. +// +// The shape that lost them: an inline, UNANNOTATED arrow in a generic call +// whose member is declared as `(h: H) => H | Promise`. No contextual type +// reaches the arrow through the type parameter, so its body is typed by tsc's +// own inference, which drops the spread's index signature: the closure is +// typed Promise<{ x }>, `{ ...h, x }` lowers through the checked-dynamic +// spread into that closed record, and the caller's keys survive only in the +// record's kept source view. The call site reads the member through the +// declared H and adapts the function value; converting `{ x }` into H must +// copy the kept keys. Node prints "accept,x"; before the fix this program +// printed "x" for the arrow, local and method lines, with no diagnostic. +type H = Record; + +interface T { + transformHeaders?: (h: H) => H | Promise; +} + +function keys(r: H): string { + return Object.keys(r).sort().join(",") || "(none)"; +} + +// The failing shape: block-bodied inline arrow, generic parameter. +async function viaArrow(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} + +// The same call read into a local first (the pi repro's shape). +async function viaLocal(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) { + const r = await o.transformHeaders(h); + console.log("local-mid:", keys(r)); + h = r; + } + return h; +} + +interface Methods { + transform(h: H): Promise; +} + +// The method form drops the same keys through the same capture. +async function viaMethod(o: O): Promise { + return await o.transform({ accept: "a" }); +} + +// A non-generic parameter: the arrow's contextual slot is the declared member, +// so these keep the index signature and never needed the capture. +async function viaArrowNonGeneric(o: T | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} + +interface Hooks { + transform?: (h: H) => H | Promise; +} + +interface Nested { + hooks: Hooks; +} + +async function viaNested(o: O | undefined): Promise { + let h: H = { accept: "a" }; + const t = o?.hooks.transform; + if (t) h = await t(h); + return h; +} + +function syncArrow H }>(o: O): H { + return o.transform ? o.transform({ accept: "a" }) : { accept: "a" }; +} + +async function main(): Promise { + const arrow = await viaArrow({ + transformHeaders: async (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("arrow:", keys(arrow), JSON.stringify(arrow)); + + const local = await viaLocal({ + transformHeaders: async (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("local:", keys(local)); + + const method = await viaMethod({ + async transform(h) { + return { ...h, x: "1" }; + }, + }); + console.log("method:", keys(method)); + + const generic = await viaArrowNonGeneric({ + transformHeaders: async (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("non-generic:", keys(generic)); + + const expression = await viaArrowNonGeneric({ + transformHeaders: async (h) => ({ ...h, x: "1" }), + }); + console.log("expression:", keys(expression)); + + const nested = await viaNested({ + hooks: { + transform: async (h) => { + return { ...h, x: "1" }; + }, + }, + }); + console.log("nested:", keys(nested)); + + const sync = syncArrow({ + transform: (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("sync:", keys(sync)); +} + +void main(); diff --git a/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json b/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json new file mode 100644 index 000000000..81f013248 --- /dev/null +++ b/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json @@ -0,0 +1 @@ +{ "plain": 1450, "san": 2550 } From 2b03f66ff3c4c47d9b42995f03b30652ad5fd701 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:16:00 -0700 Subject: [PATCH 085/193] Host patching H1: typed process output write slot Reading `process.stdout.write` as a value, `write.bind(stream)`, and `process.stdout.write = fn` now reach the runtime stream object's write slot in TypeScript, and a program that replaces an output write method calls its typed writes through that slot (SC8020 refuses the combination with Node islands until H2 shares the stream object with them). Co-Authored-By: Claude Opus 5.5 --- .../console-and-process-streams.mdx | 3 +- .../generated/node-v24-internal.json | 4 + internal/compatibility/static-support.json | 2 +- .../compiler/src/diagnostics/diagnostic.ts | 18 +++ packages/compiler/src/executable/prepare.ts | 9 ++ .../src/frontend/lowering/builtins/process.ts | 112 ++++++++++++++++++ .../src/frontend/lowering/lower-stmts.ts | 35 +++++- .../compiler/src/frontend/lowering/lowerer.ts | 24 ++++ packages/compiler/src/frontend/type-mapper.ts | 36 ++++++ .../tests/corpus/stdout-write-patch.ts.json | 6 + .../native-shim-monkeypatching.ts.json | 6 + packages/runtime/src/scr_events.c | 3 +- tests/corpus/stdout-write-patch.ts | 42 +++++++ .../diagnostics/native-shim-monkeypatching.ts | 20 ++++ .../native-shim-monkeypatching.ts.txt | 26 ++++ .../corpus-costs/stdout-write-patch.ts.json | 1 + 16 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json create mode 100644 tests/corpus/stdout-write-patch.ts create mode 100644 tests/diagnostics/native-shim-monkeypatching.ts create mode 100644 tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt create mode 100644 tests/harness/corpus-costs/stdout-write-patch.ts.json diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index 946cb8061..a877512c1 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -12,7 +12,8 @@ Stored global console values and detached output methods retain identity and acc Stored methods do not support format specifiers with substitution arguments. Other stored console APIs, custom `Console` instances, and console mutation are unsupported. -JavaScript can store `process.stdin`, `stdout`, and `stderr`, replace output `write` methods, and call saved methods with their stream receiver. +JavaScript and TypeScript can store `process.stdin`, `stdout`, and `stderr`, replace output `write` methods, and call saved methods with their stream receiver. +A replaced `write` method does not receive console's error-callback argument. Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode. Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled. Stored streams do not support piping, encoding changes, arbitrary properties, or other events. diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 00d6f8db3..240eeceda 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79689,6 +79689,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdio-write-overrides.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/fixtures/node-types/env-files.ts", "tests/fixtures/node-types/util-callables.ts", "tests/harness/event-loop.test.ts", @@ -82047,6 +82048,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82089,6 +82091,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82131,6 +82134,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1242ad9a7..72ed41fdb 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -314,7 +314,7 @@ { "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] }, { "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] }, { "chapter": "errors", "signatures": ["`stack` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/error-stack-frames.ts", "tests/corpus/error-stack-limit.ts"] }, - { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/harness/event-loop.test.ts"] }, + { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts"] }, { "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] }, { "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] }, { "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] }, diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0cabb6ddb..3322b3af8 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -366,6 +366,24 @@ export function sharedModuleDuplicatedDiag( }; } +/** SC8020 — the program replaces a process output stream's write method + * while a Node island exists: the island's `process.stdout` is its own + * object, so the replacement and the island's writes cannot see each other. + * H2 shares the native stream object with the islands and removes this. */ +export function stdioPatchAcrossIslandsDiag( + stream: "stdout" | "stderr" | null, + loc: SrcLoc, +): ScrDiagnostic { + const what = + stream === null ? "process.stdout.write or process.stderr.write" : `process.${stream}.write`; + return { + code: "SC8020", + message: `${what} is replaced while a Node island exists: the island's process output stream would not see this patch, and island writes would bypass it`, + loc, + hint: "the island's process.stdout would not see this patch", + }; +} + /** SC8022 — island code extends a class of a module the program compiles. * A native instance cannot take an island subclass's overrides, so the * module is not owned by the program: a package runs in Node for both diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index f2b955f85..19081e35e 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -7,6 +7,7 @@ import { npmStaticRefusalDiag, iceDiag, isCheckerPanic, + stdioPatchAcrossIslandsDiag, type ScrDiagnostic, } from "../diagnostics/diagnostic.js"; import type { IrModule, SrcLoc } from "../ir/ir.js"; @@ -168,6 +169,14 @@ export function prepareExecutableModule( return fail(lowered.diagnostics); } + // A program that replaces a process output stream's write method has + // nowhere to route an island's writes: the island's process.stdout is + // its own object. Until islands share the native stream object (H2), + // refuse the combination instead of silently missing the patch. + if (islandDecision !== null && lowered.stdioPatches !== undefined) { + return fail(lowered.stdioPatches.map((p) => stdioPatchAcrossIslandsDiag(p.stream, p.loc))); + } + const validation = validateModule(lowered.module); timing("ir-validate"); if (validation.length > 0) { diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2eac7a17d..065183a69 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -34,6 +34,7 @@ import { builtinFunctionValue } from "../lower-exprs.js"; import { canonicalBuiltinModule } from "../../builtin-modules.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js"; +import { isCheckerPanic } from "../../../diagnostics/diagnostic.js"; /** Whether the process has an IPC channel, Node's test for defining * process.send and process.disconnect: a channel this fork child opened @@ -173,6 +174,70 @@ export function programReadsExitCode(lowerer: Lowerer): boolean { return found; } +/** A write-method replacement on a process output stream, its source site. + * `stream` is null when the receiver is a first-class alias, whose type does + * not say which of the two output streams it holds. */ +export interface StdioPatchSite { + stream: "stdout" | "stderr" | null; + loc: SrcLoc; +} + +/** Whether any program file replaces a process output stream's write + * method: `process.stdout.write = …` / `process.stderr.write = …`, or the + * same assignment through a first-class alias of the stream (a value whose + * type maps to the process stream). Such a program routes its typed + * stdout/stderr writes through the stream object, so the replacement sees + * them with their arguments, as in Node. Cached per program; the first + * site found is recorded for the executable planner's SC8020 check. */ +export function programPatchesStdio(lowerer: Lowerer): boolean { + if (lowerer.patchesStdio !== null) return lowerer.patchesStdio; + let found = false; + // undefined: not an output stream; null: a first-class alias of one. + const streamOf = (recv: ts.Expression): "stdout" | "stderr" | null | undefined => { + if (ts.isPropertyAccessExpression(recv)) { + const direct = lowerer.stdlibGlobalMember(recv, "process"); + if (direct === "stdout" || direct === "stderr") return direct; + } + // An arbitrary receiver's mapping is a query, never a commit: a checker + // panic (the type has no readable form) is not a patch site. The type + // cannot say WHICH output stream an alias holds. + try { + return lowerer.mapTypeOf(lowerer.typeOf(recv))?.kind === "procStream" ? null : undefined; + } catch (e) { + if (!isCheckerPanic(e)) throw e; + return undefined; + } + }; + const walk = (node: ts.Node): void => { + if (found) return; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.name.text === "write" + ) { + const recv = node.left.expression; + const direct = ts.isPropertyAccessExpression(recv) + ? lowerer.stdlibGlobalMember(recv, "process") + : null; + const stream = direct === "stdout" || direct === "stderr" ? direct : streamOf(recv); + if (stream !== undefined) { + found = true; + lowerer.stdioPatchSites.push({ stream, loc: locOf(node) }); + return; + } + } + ts.forEachChild(node, walk); + }; + for (const sf of lowerer.program.getImplementationSourceFiles()) { + if (sf.isDeclarationFile) continue; + walk(sf); + if (found) break; + } + lowerer.patchesStdio = found; + return found; +} + /** The pinned Node release the runtime reports as process.versions.node * (packages/runtime/src/scr_lib.c SCR_NODE_COMPAT_VERSION, kept equal to * internal/compatibility/node-v24.json by test/node-pin.test.ts). */ @@ -341,6 +406,26 @@ export function lowerProcessStreamProperty( loc: locOf(expr), }; } + // The write method as a VALUE in TypeScript (`const original = + // process.stdout.write`, `process.stdout.write.bind(process.stdout)`): + // the stream object's current write slot, a checked-dynamic function + // (the native method until a program replaces it). + if (member === "write" && (direct === "stdout" || direct === "stderr")) { + const loc = locOf(expr); + return { + kind: "dynKeyGet", + value: { + kind: "libCall", + fn: "process.stdio", + args: [numLit(direct === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }, + key: strLit("write", locOf(expr.name)), + type: DYN, + loc, + }; + } // `if (process.stdin.setRawMode)`: Node's stdin has the method only // when it is a TTY. The runtime's stream object answers the same // presence (the method or undefined); calls keep their direct lowering. @@ -1073,6 +1158,33 @@ export function lowerProcessMethodCall( if (stream === "stdout" || stream === "stderr") { const loc = locOf(call); const args = call.arguments.nodes; + // In a program that replaces a process stream's write, a write call is + // what Node makes it: a call of the stream's CURRENT write property, + // with the arguments as passed (the replacement sees byte chunks, + // encodings and callbacks). The native method behind the slot is the + // same synchronous write, so an unpatched stream behaves as before. + if (programPatchesStdio(lowerer) && !args.some(ts.isSpreadElement)) { + const handle: IrExpr = { + kind: "libCall", + fn: "process.stdio", + args: [numLit(stream === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }; + const invoke: IrExpr = { + kind: "dynInvoke", + recv: handle, + method: "write", + calleeName: ts.getTextOfNode(access), + args: args.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + type: DYN, + loc, + }; + // The declared boolean: a replacement returning something else + // throws at a use that needs the boolean (types do not lie); a + // discarded result is not converted. + return resultIsDiscarded(call) ? invoke : lowerer.coerceToExpected(invoke, BOOL); + } if (args.length < 1 || args.length > 3 || args.some(ts.isSpreadElement)) { lowerer.noLowering( `process.${stream}.write with ${args.length} arguments`, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28d7e7c22..cfea92522 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -25,7 +25,7 @@ import { matchAllRowsReadByElements, storedMatchAllRowsReadByElements, } from "./containers/match-row-uses.js"; -import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; +import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; import { runtimeKeyPatternRead } from "./expressions/keyed-field-targets.js"; import { declaresHoistedBinding, staticCondition } from "./static-typeof.js"; import { lowerCollectionSpread } from "./containers/collection-methods.js"; @@ -9196,6 +9196,39 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm }; } } + // process.stdout.write = fn (and stderr): replace the stream object's + // write slot. Every later write to the stream — console output, typed + // write calls (programPatchesStdio), runtime writers — goes through it. + if (ts.isPropertyAccessExpression(expr.left) && expr.left.name.text === "write") { + const recv = expr.left.expression; + const stream = ts.isPropertyAccessExpression(recv) + ? lowerer.stdlibGlobalMember(recv, "process") + : null; + if (stream === "stdout" || stream === "stderr") { + const loc = locOf(expr); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [ + { + kind: "libCall", + fn: "process.stdio", + args: [numLit(stream === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }, + strLit("write", locOf(expr.left.name)), + lowerer.lowerExprExpecting(expr.right, DYN), + ], + type: VOID, + loc, + }, + loc, + }; + } + } if (!ts.isIdentifier(expr.left)) { lowerer.unsupported("SC1090", expr.left, "assignment to non-variables"); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 027e8a8f6..37b35ceaa 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -100,6 +100,8 @@ import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions. import { isOptionalProcessStreamProperty, isOptionalProcessVersionsRead, + programPatchesStdio, + type StdioPatchSite, } from "./builtins/process.js"; import { isSettledIteratorValueRead } from "./lower-generators.js"; import { isRebindableFunctionSymbol } from "./rebindable-functions.js"; @@ -952,6 +954,11 @@ export interface LowerResult { dynamicFunctions?: DynamicFunction[]; /** LowerOptions.tierReport: the report-only tier assignment. */ tiers?: TierReport; + /** The program replaces a process output stream's write method, with the + * site: the executable planner refuses the combination with Node islands + * (SC8020), whose process objects would not see the patch. Present only + * when the build has islands. */ + stdioPatches?: StdioPatchSite[]; } export interface LowerOptions { @@ -2605,6 +2612,12 @@ export class Lowerer { streamPropMutatedSyms: Set | null = null; /** Whether some source reads process.exitCode (processReadsExitCode). */ readsExitCode: boolean | null = null; + /** Whether some source replaces a process output stream's write method + * (programPatchesStdio in builtins/process.ts). */ + patchesStdio: boolean | null = null; + /** The write-replacement site programPatchesStdio found: the executable + * planner refuses it beside Node islands (SC8020). */ + readonly stdioPatchSites: StdioPatchSite[] = []; requestInitPropMutatedSyms: Set | null = null; emitterEventTable: Map | null = null; emitterComputedPatterns: ComputedEventPattern[] = []; @@ -4395,6 +4408,8 @@ export class Lowerer { "unixListenReachable", "streamPropMutatedSyms", "readsExitCode", + "patchesStdio", + "stdioPatchSites", "requestInitPropMutatedSyms", "classProtocols", "cjsSplitRequireBindings", @@ -9720,11 +9735,20 @@ export class Lowerer { // rewrite keeps every dynamic operation dynamic (each dyn operator // becomes its js twin), so coverage's dynamic statements are unchanged. if (module && this.dynamicFunctions.length > 0) promoteJsvals(module); + // A program that replaces a process output stream's write method beside + // Node islands is refused by the executable planner (SC8020): an + // island's process.stdout is its own object. Only island builds pay for + // the whole-program scan. + const stdioPatches = + this.nodeIslands !== null && programPatchesStdio(this) + ? [...this.stdioPatchSites] + : undefined; return { module, diagnostics: this.diags, runtimeFences: this.runtimeFences, stats: this.stats, + ...(stdioPatches !== undefined && stdioPatches.length > 0 ? { stdioPatches } : {}), ...(this.dynamicFunctions.length > 0 ? { dynamicFunctions: this.dynamicFunctions } : {}), ...(this.statsByFile.size > 0 ? { statsByFile: this.statsByFile } : {}), ...(this.provenanceElided.length > 0 ? { provenanceElided: this.provenanceElided } : {}), diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 36dae9b10..47d8d891d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5101,6 +5101,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // AsyncLocalStorage.snapshot()'s generic runner is a dyn function value // (als.snapshot), called through the dyn call path. if (callSigs.length === 1 && isAsyncLocalStorageSnapshot(callSigs[0]!, ctx)) return DYN; + // A process/net/stream `write` method taken as a value (`typeof + // process.stdout.write`, the saved original of an output guard): its + // overload set has no single compiled signature, and the value is the + // stream's replaceable method slot, so it is the checked-dynamic function + // the stream object hands out (scr_events.c ProcessStream). + if (callSigs.length > 1 && isStdlibStreamWriteOverloads(callSigs, ctx)) return DYN; if (callSigs.length === 1) return mapCallSignature(callSigs[0]!, ctx); // Records: object types whose members are all data properties (shorthand // methods in type position count — they're func-typed fields) with @@ -5503,6 +5509,36 @@ function isAsyncLocalStorageSnapshot(sig: ts.Signature, ctx: TypeMapperCtx): boo ); } +const STREAM_WRITE_OWNERS = new Set(["Socket", "Writable", "WriteStream", "WritableStream"]); + +/** Every signature is a `write` method declared on a standard-library + * stream interface or class (net.Socket, stream.Writable, tty.WriteStream, + * NodeJS.WritableStream) — @types/node's or the fallback declarations'. */ +function isStdlibStreamWriteOverloads( + sigs: readonly ts.Signature[], + ctx: Pick, +): boolean { + return sigs.every((sig) => { + const decl = sig.declaration; + if ( + decl === undefined || + !(ts.isMethodSignatureDeclaration(decl) || ts.isMethodDeclaration(decl)) + ) + return false; + if (!ts.isIdentifier(decl.name) || decl.name.text !== "write") return false; + const owner = decl.parent; + const ownerName = + owner !== undefined && (ts.isInterfaceDeclaration(owner) || ts.isClassDeclaration(owner)) + ? (owner.name?.text ?? null) + : null; + return ( + ownerName !== null && + STREAM_WRITE_OWNERS.has(ownerName) && + ctx.isStdlibFile(ts.getSourceFileOfNode(decl)) + ); + }); +} + function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null { const { checker } = ctx; if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json new file mode 100644 index 000000000..bacb95fe0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stdout-write-patch.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json new file mode 100644 index 000000000..c1f13438c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/native-shim-monkeypatching.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 78b7bb0f1..9de2c6ce2 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1580,7 +1580,8 @@ ScrDyn *scr_process_stdio(double fd) { ScrClosure *cb = scr_closure_new(NULL, 1); cb->caps[0] = scr_box_new(SCR_BOX_F64); scr_box_set_f64(cb->caps[0], i); - scr_stdio_methods[i] = scr_dyn_new_func(cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", scr_stdio_names[i]); + scr_stdio_methods[i] = scr_dyn_new_func( + cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", i == STDIO_WRITE ? "" : scr_stdio_names[i]); } scr_stdio_write_hook = scr_stdio_intercept; } diff --git a/tests/corpus/stdout-write-patch.ts b/tests/corpus/stdout-write-patch.ts new file mode 100644 index 000000000..c5e960be0 --- /dev/null +++ b/tests/corpus/stdout-write-patch.ts @@ -0,0 +1,42 @@ +// Replacing process.stdout.write from TypeScript: the output-guard idiom. +// The real write methods are taken first with bind (they write straight +// to the descriptor, past any later patch). The replacement then sees +// every stdout write: console.log's lines, direct string and byte writes +// with their encoding and callback arguments as passed. Assigning the +// saved method back restores the stream. +type RawWrite = (chunk: string, callback?: (error?: Error | null) => void) => boolean; + +const rawStdout = process.stdout.write.bind(process.stdout) as RawWrite; +const rawStderr = process.stderr.write.bind(process.stderr) as RawWrite; +const original = process.stdout.write; +const captured: string[] = []; + +function text(chunk: string | Uint8Array): string { + return typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); +} + +process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, +): boolean => { + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : "-"; + captured.push(`${typeof chunk === "string" ? "string" : "bytes"}/${encoding}:${text(chunk).trimEnd()}`); + return rawStderr(`[redirected] ${text(chunk)}`, done); +}) as typeof process.stdout.write; + +console.log("from console.log", 42); +process.stdout.write("direct string\n"); +process.stdout.write(new Uint8Array([104, 105, 10])); +process.stdout.write("6869210a", "hex"); +process.stdout.write("with callback\n", (error) => { + rawStderr(`callback error is null: ${error === null}\n`); +}); +const accepted = process.stdout.write("result\n"); +rawStdout("raw stdout bypasses the patch\n"); +console.log("same method", process.stdout.write === original, accepted); + +process.stdout.write = original; +console.log("restored", process.stdout.write === original); +console.log(captured.join(" | ")); diff --git a/tests/diagnostics/native-shim-monkeypatching.ts b/tests/diagnostics/native-shim-monkeypatching.ts new file mode 100644 index 000000000..5a6613be1 --- /dev/null +++ b/tests/diagnostics/native-shim-monkeypatching.ts @@ -0,0 +1,20 @@ +// Patching a NATIVE host object: Node lets a library swap +// `process.stdout.write`, hand out `process.stdout.write.bind(...)`, or +// redefine a module member with Object.defineProperty. The process output +// stream's write slot is the runtime stream object's, so the assignment and +// the bind compile. A builtin module's exports are direct calls, so those +// forms keep an explicit refusal instead of silently patching nothing. The +// working split: use the module's functions directly. + +import * as fs from "node:fs"; + +export function bindReadFileSync(): typeof fs.readFileSync { + return fs.readFileSync.bind(fs); +} + +export function defineReadFileSync(): void { + Object.defineProperty(fs, "readFileSync", { value: () => "" }); +} + +bindReadFileSync(); +defineReadFileSync(); diff --git a/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt new file mode 100644 index 000000000..d25f2e984 --- /dev/null +++ b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt @@ -0,0 +1,26 @@ +native-shim-monkeypatching.ts:11:17 - error SC2007: values of type '{ (path: string | number | URL, encoding: BufferEncoding): string; (path: string | number | URL, options: { encoding: BufferEncoding; }): string; (path: string | ... 1 more ... | URL): Buffer<...>; (path: string | URL, options: unknown): string | Buffer<...>; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature + + 10 | + 11 | export function bindReadFileSync(): typeof fs.readFileSync { + | ^~~~~~~~~~~~~~~~ + 12 | return fs.readFileSync.bind(fs); + + hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow + +native-shim-monkeypatching.ts:16:3 - error SC2020: 'Object.defineProperty' is part of the standard library types but has no scriptc lowering yet + + 15 | export function defineReadFileSync(): void { + 16 | Object.defineProperty(fs, "readFileSync", { value: () => "" }); + | ^~~~~~~~~~~~~~~~~~~~~ + 17 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +native-shim-monkeypatching.ts:19:1 - error SC2004: uses of 'bindReadFileSync' inherit the blocker on its declaration + + 18 | + 19 | bindReadFileSync(); + | ^~~~~~~~~~~~~~~~ + 20 | defineReadFileSync(); + + hint: the declaration of 'bindReadFileSync' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/corpus-costs/stdout-write-patch.ts.json b/tests/harness/corpus-costs/stdout-write-patch.ts.json new file mode 100644 index 000000000..7a430121c --- /dev/null +++ b/tests/harness/corpus-costs/stdout-write-patch.ts.json @@ -0,0 +1 @@ +{ "plain": 3200, "san": 2700 } From cf369f70af1f0e0d28046c4c49333c7633755fef Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:51:28 -0700 Subject: [PATCH 086/193] Fix write callbacks that take the error argument (W3) `writable.write(chunk, cb)` with `cb` passed as a function VALUE lost the completion's error: `lowerWriteCb` checked only the callback's syntax and lowered every other callback through `lowerExprExpecting(cbArg, funcOf([], VOID))`, which adapts a one-parameter function down to zero parameters. The native binary therefore printed `undefined` where Node prints `null`, or the write's own error (`ERR_STREAM_WRITE_AFTER_END` / `ERR_STREAM_DESTROYED` for late and destroyed writes). Lower such callbacks through a new `writable.writeEcb` libCall `(recv, mode, chunk, encoding, cb)`: a zero-parameter callback keeps the existing synchronous path, and a callback that declares the error (a dyn value, or `null | Error`) is wrapped by `scr_stream_ecb_wrap` with the compiler-built adapter that delivers the error the write completion carries. The stream unit publishes that error only in its `SCR_STREAM_WRITE_ERRORS` build, selected by the new `streamWriteErrors` runtime feature (rule: any `writable.writeEcb`). Every other build compiles `SCR_WCB_CALL` to the same plain call, so their objects are unchanged. The destruction path also matched Node: `afterWrite` calls `cb(null)` even for a stream destroyed after the write was queued (Node 24 `internal/streams/writable`), so the destroyed `SCR_ST_AFTER_WRITE` tick passes NULL rather than the stream's stored error. Corpus: `tests/corpus/writable-error-callbacks.ts` covers success, an erroring write, a write after end, and a write on a destroyed stream. Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-internal.json | 93 ++++++++++++------- internal/compatibility/static-support.json | 2 +- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../compiler/src/backend/llvm/lib-stream.ts | 38 ++++++++ .../src/backend/native-link-info-core.ts | 3 + .../compiler/src/backend/native/contracts.ts | 3 + .../compiler/src/backend/native/executable.ts | 3 + .../compiler/src/backend/runtime-pack-core.ts | 4 + .../src/executable/executable-cache.ts | 2 + .../src/frontend/lowering/lower-stream.ts | 58 ++++++++++-- packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/src/ir/runtime-features.ts | 5 + .../compiler/src/native/sanitizer.test.ts | 1 + .../corpus/writable-error-callbacks.ts.json | 6 ++ .../runtime-darwin-arm64/scripts/build.mjs | 1 + .../runtime-pack-matrix.mjs | 10 ++ .../runtime-pack-common/scripts/build.mjs | 1 + packages/runtime/src/scr_stream.c | 77 +++++++++++++-- packages/runtime/src/scr_stream.h | 8 ++ tests/corpus/writable-error-callbacks.ts | 53 +++++++++++ .../writable-error-callbacks.ts.json | 1 + 24 files changed, 330 insertions(+), 47 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json create mode 100644 tests/corpus/writable-error-callbacks.ts create mode 100644 tests/harness/corpus-costs/writable-error-callbacks.ts.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 240eeceda..928bea3bb 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -86937,7 +86937,8 @@ "tests/corpus/5611-readable-from-class-iterables.ts", "tests/corpus/5612-readable-from-iterables.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "chapter" @@ -87063,7 +87064,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87097,7 +87099,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87131,7 +87134,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87188,7 +87192,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87222,7 +87227,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87256,7 +87262,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87652,7 +87659,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87709,7 +87717,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87743,7 +87752,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87800,7 +87810,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -87834,7 +87845,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -87868,7 +87880,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -88264,7 +88277,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -88367,7 +88381,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88427,7 +88442,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88461,7 +88477,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88495,7 +88512,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88552,7 +88570,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89145,7 +89164,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89179,7 +89199,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89213,7 +89234,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89247,7 +89269,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89281,7 +89304,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89315,7 +89339,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89349,7 +89374,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89383,7 +89409,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89417,7 +89444,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -90836,7 +90864,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -91100,7 +91129,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -91134,7 +91164,8 @@ "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 72ed41fdb..96e4418db 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -478,7 +478,7 @@ { "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] }, { "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] }, { "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] }, - { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs"] }, + { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs", "tests/corpus/writable-error-callbacks.ts"] }, { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs"] }, { "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] }, { "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..f333aff7e 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -20,6 +20,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02479a922..9ed4e3a18 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1950,6 +1950,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "writable.writeU", "writable.writeDyn", "writable.writeEnc", + "writable.writeEcb", "writable.end", "writable.uncork", "stream.destroy", diff --git a/packages/compiler/src/backend/llvm/lib-stream.ts b/packages/compiler/src/backend/llvm/lib-stream.ts index 53f455964..89a97d002 100644 --- a/packages/compiler/src/backend/llvm/lib-stream.ts +++ b/packages/compiler/src/backend/llvm/lib-stream.ts @@ -455,6 +455,44 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV host.emitPendingCheck(); return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type }; } + if (e.fn === "writable.writeEcb") { + // (recv, mode, chunk, encoding, cb): mode 0 bytes, 1 string, 2 dyn. The + // callback MOVES into an error-first wrapper (scr_stream_ecb_wrap); the + // adapter turns the runtime's ScrError (NULL = success) into the + // callback's Error | null argument, as fs.rename callbacks do. + const modeArg = e.args[1]!; + if (modeArg.kind !== "numLit") + throw new InternalCompilerError("llvm emitter bug: writable.writeEcb mode not a literal"); + const cbT = e.args[4]!.type; + if (cbT.kind !== "func") + throw new InternalCompilerError("llvm emitter bug: writable.writeEcb callback not a func"); + const args = e.args.map((a) => host.emitExpr(a)); + host.toHeap(args[4]!); + const adapter = host.fsRenameThunkFor(cbT); + host.declare(`declare ptr @scr_stream_ecb_wrap(ptr, ptr)`); + const wrapped = B.tmp(); + B.line(`${wrapped} = call ptr @scr_stream_ecb_wrap(ptr ${args[4]!.name}, ptr @${adapter})`); + const entry = + modeArg.value === 0 + ? "scr_stream_write" + : modeArg.value === 1 + ? "scr_stream_write_str" + : null; + const t = B.tmp(); + if (entry !== null) { + host.declare(`declare zeroext i1 @${entry}(ptr, ptr, ptr)`); + B.line( + `${t} = call zeroext i1 @${entry}(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${wrapped})`, + ); + } else { + host.declare(`declare zeroext i1 @scr_stream_write_enc(ptr, ptr, ptr, ptr)`); + B.line( + `${t} = call zeroext i1 @scr_stream_write_enc(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${args[3]!.name}, ptr ${wrapped})`, + ); + } + host.emitPendingCheck(); + return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type }; + } if (e.fn === "writable.end") { // (recv, flags[, chunk][, encoding][, cb]) — flags: 1 bytes chunk, 2 // string chunk, 8 dyn chunk (write first, Node's end(chunk) diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..54aa87b4d 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -34,6 +34,9 @@ export interface NativeLinkFeatures { /** The program uses process.stdin.setEncoding, string 'data' listeners * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ stdioExtended?: boolean; + /** A write completion callback takes the error argument: links the + * SCR_STREAM_WRITE_ERRORS build of the stream unit. */ + streamWriteErrors?: boolean; /** Calls into Node.js (@scriptc/node): scr_node_island.c and the * SCR_NODE_ISLANDS variant of the event loop unit. */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..a58d2310f 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -161,6 +161,9 @@ export interface CcOptions { warningReplace?: boolean; /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ stdioExtended?: boolean; + /** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each + * write completion callback receives (error-taking write callbacks). */ + streamWriteErrors?: boolean; /** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and * the event loop with SCR_NODE_ISLANDS (its helper slot). */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..4d099a6d9 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -409,6 +409,9 @@ const RUNTIME_DEFINES: readonly { { define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true }, { define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace }, { define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended }, + // scr_stream.c's write-callback error publication, which the compiler's + // error-first write adapter reads. + { define: "SCR_STREAM_WRITE_ERRORS", when: (opts) => opts.streamWriteErrors }, // scr_async.c's native promise reactions, which the web streams unit uses, // scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes, // and scr_fetch.c's bodies of its streams. diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 2ce4f21fe..cd22be057 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -101,6 +101,9 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { computedRequire: boolean; warningReplace: boolean; stdioExtended: boolean; + /** The program's write completion callbacks take the error argument: + * the SCR_STREAM_WRITE_ERRORS build of the stream unit. */ + streamWriteErrors: boolean; nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; @@ -312,6 +315,7 @@ export function effectiveRuntimeFeatures( computedRequire: features.computedRequire === true, warningReplace: features.warningReplace === true, stdioExtended: features.stdioExtended === true, + streamWriteErrors: features.streamWriteErrors === true, nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..36965eb89 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -65,6 +65,8 @@ export interface EarlyExecutableNativeFeatures { wasm?: boolean; warningReplace?: boolean; stdioExtended?: boolean; + /** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */ + streamWriteErrors?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index d8c370511..6d203ae7f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6964,18 +6964,39 @@ export function lowerStreamMethodCall( const kind = chunkKind(args[0]!); if (kind === "null") lowerer.noLowering("write(null)", call, "end() finishes a writable"); const lowerWriteCb = (cbArg: ts.Expression): IrExpr => { - const cb = callbackDeclaresParameters(cbArg) - ? null - : lowerer.lowerExprExpecting(cbArg, funcOf([], VOID)); - if (cb === null || cb.type.kind !== "func" || cb.type.params.length > 0) { + const cb = lowerer.lowerExpr(cbArg); + if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.noLowering( - "write completion callbacks taking the error argument", + "write completion callbacks of this shape", cbArg, - "() => void callbacks are supported (listen for 'error' to observe failures)", + "callbacks take () or (error?: Error | null) and return nothing", ); } - return cb!; + if (cb.type.params.length === 0) return cb; + const param = cb.type.params[0]!; + const def = param.kind === "union" ? lowerer.unions.get(param.unionId) : undefined; + const ok = + param.kind === "dyn" || + (!!def && + def.arms.some((a) => a.kind === "nullT") && + def.arms.some((a) => a.kind === "object" && a.className === "%Error") && + def.arms.every( + (a) => + a.kind === "nullT" || + a.kind === "undefinedT" || + (a.kind === "object" && a.className === "%Error"), + )); + if (!ok) { + lowerer.unsupported( + "SC1090", + cbArg, + `write completion callbacks whose parameter is not 'Error | null' (got '${lowerer.fmt(param)}')`, + ); + } + return cb; }; + const takesError = (cb: IrExpr): boolean => + cb.type.kind === "func" && cb.type.params.length === 1; // write(chunk, encoding[, cb]), and write(value, cb): the encoding // argument reaches an object-mode side's _write as passed, null when // the callback took its place (a byte-mode side encodes a string @@ -6988,7 +7009,19 @@ export function lowerStreamMethodCall( const chunk = lowerer.lowerExprExpecting(args[0]!, DYN); const tail: IrExpr[] = []; if (encArg !== undefined) tail.push(lowerer.lowerExprExpecting(encArg, DYN)); - if (cbArg !== undefined) tail.push(lowerWriteCb(cbArg)); + if (cbArg !== undefined) { + const cb = lowerWriteCb(cbArg); + if (takesError(cb)) { + return { + kind: "libCall", + fn: "writable.writeEcb", + args: [receiver, numLit(2, loc), chunk, tail[0] ?? dynUndefinedExpr(loc), cb], + type: BOOL, + loc, + }; + } + tail.push(cb); + } const flags = (encArg !== undefined ? 1 : 0) | (cbArg !== undefined ? 2 : 0); return { kind: "libCall", @@ -7055,6 +7088,15 @@ export function lowerStreamMethodCall( const fn = kind === "bytes" ? "writable.write" : "writable.writeStr"; if (cbArg !== undefined) { const cb = lowerWriteCb(cbArg); + if (takesError(cb)) { + return { + kind: "libCall", + fn: "writable.writeEcb", + args: [receiver, numLit(kind === "bytes" ? 0 : 1, loc), chunk, dynUndefinedExpr(loc), cb], + type: BOOL, + loc, + }; + } return { kind: "libCall", fn, args: [receiver, chunk, cb], type: BOOL, loc }; } return { kind: "libCall", fn, args: [receiver, chunk], type: BOOL, loc }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..ecd6734a8 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -700,6 +700,7 @@ async function compileExecutableNative( ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..114231730 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -438,6 +438,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "writable.writeU", "writable.writeDyn", "writable.writeEnc", + "writable.writeEcb", "writable.end", "writable.uncork", "writable.setDefaultEncoding", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..55596d087 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4576,6 +4576,10 @@ function buildLibFnSignatures() { "writable.writeU": { argTypes: [null, null], result: BOOL }, "writable.writeDyn": { argTypes: [null, DYN], result: BOOL }, "writable.writeEnc": { argTypes: [null, F64, DYN], result: BOOL }, + /** The error-first write form: (recv, mode, chunk, encoding, cb) where + * mode 0 is a bytes chunk, 1 a string, 2 a run-time chunk, and the + * callback takes the completion's error (Node's null on success). */ + "writable.writeEcb": { argTypes: [null, F64, null, DYN, null], result: BOOL }, "writable.end": { argTypes: [null, F64], result: VOID }, "writable.cork": { argTypes: [null], result: VOID }, "writable.uncork": { argTypes: [null], result: VOID }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..93b78db14 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1185,6 +1185,10 @@ const RUNTIME_FEATURE_RULES = { (node.kind === "dynInvoke" && STDIO_EXTENDED_NAMES.has(node.method)) || (node.kind === "strLit" && STDIO_EXTENDED_NAMES.has(node.value)), }), + /** A write completion callback that takes the error argument lowers + * through writable.writeEcb: the SCR_STREAM_WRITE_ERRORS stream unit + * publishes the error the adapter delivers. */ + streamWriteErrors: rule({ fns: ["writable.writeEcb"] }), stream: rule({ initial: (mod) => (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), fns: [...STREAM_LIB_FNS, "dyn.builtinStreamClass"], @@ -1309,6 +1313,7 @@ function noRuntimeFeatures(): RuntimeFeatures { searchParams: false, urlIdna: false, stdioExtended: false, + streamWriteErrors: false, moduleNamespace: false, moduleNamespaceDyn: false, stream: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index b1d03b52d..0e7bccafd 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -32,6 +32,7 @@ const features = { computedRequire: false, warningReplace: false, stdioExtended: false, + streamWriteErrors: false, nodeIsland: false, brotli: false, moduleNamespace: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json new file mode 100644 index 000000000..bc5a7f0ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/writable-error-callbacks.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index ecb2a901b..24bde2db5 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -376,6 +376,7 @@ async function build() { "SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", + "SCR_STREAM_WRITE_ERRORS", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..5e3b722d2 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -334,6 +334,16 @@ export function variantsFor(source) { defines: ["SCR_NODE_ISLANDS"], }); } + // Error-taking write completion callbacks: the stream unit publishes the + // error each callback receives; every other program keeps the default + // object, byte for byte. + if (source === "scr_stream.c") { + variants.push({ + id: "write-errors", + when: { streamWriteErrors: true }, + defines: ["SCR_STREAM_WRITE_ERRORS"], + }); + } if (source === "scr_cycle.c") { // The young-in nursery (SCRIPTC_YOUNG_NURSERY=1): the collector with // nursery collections; programs built without it link the default. diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 97e03b189..e8af727ea 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -545,6 +545,7 @@ async function build() { "SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", + "SCR_STREAM_WRITE_ERRORS", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 872586976..dfdccc238 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -2171,6 +2171,59 @@ static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, S st->w.wsync = false; } +#ifdef SCR_STREAM_WRITE_ERRORS +/* The error the next user write callback receives (borrowed; NULL is + * success, Node's null). Set immediately around each user write-callback + * call below; read by the error-first wrapper scr_stream_ecb_fire. */ +static SCR_TL ScrError *scr_stream_cb_error = NULL; +#define SCR_WCB_CALL(cb, err) \ + do { \ + ScrError *scr_wcb_saved = scr_stream_cb_error; \ + scr_stream_cb_error = (err); \ + ((void (*)(ScrClosure *))(cb)->fn)(cb); \ + scr_stream_cb_error = scr_wcb_saved; \ + } while (0) +/* The errorBuffer/destroyed-afterWrite form: the stream's own error, or + * the fresh ERR_STREAM_DESTROYED Node passes (owned around the call). */ +#define SCR_WCB_CALL_ERROF(cb, st) \ + do { \ + ScrError *scr_wcb_err = (st)->errored \ + ? scr_error_retain((st)->errored) \ + : scr_stream_mkerr("ERR_STREAM_DESTROYED", \ + "Cannot call write after a stream was destroyed"); \ + SCR_WCB_CALL(cb, scr_wcb_err); \ + scr_error_release(scr_wcb_err); \ + } while (0) + +/* An error-first user callback behind the zero-argument write-callback ABI: + * caps[0] holds the user's closure, caps[1] the compiler-built adapter + * (ScrFsRenameFn: (cb, err-or-NULL) — NULL is Node's null). */ +static void *scr_stream_ecb_retain(void *p) { return p; } +static void scr_stream_ecb_release(void *p) { (void)p; } +static void scr_stream_ecb_trace(void *p, ScrTraceVisit visit, void *ctx) { + (void)p; + (void)visit; + (void)ctx; +} +static void scr_stream_ecb_fire(ScrClosure *wrapper) { + ScrClosure *user = (ScrClosure *)scr_box_get_ref(wrapper->caps[0]); + ScrFsRenameFn fn = (ScrFsRenameFn)scr_box_get_ref(wrapper->caps[1]); + fn(user, scr_stream_cb_error); + scr_closure_release(user); /* scr_box_get_ref returned +1 */ +} +ScrClosure *scr_stream_ecb_wrap(ScrClosure *cb /*moves*/, ScrFsRenameFn fn) { + ScrClosure *w = scr_closure_new((void *)scr_stream_ecb_fire, 2); + w->caps[0] = scr_box_new_obj(scr_closure_retain_v, scr_closure_release_v, scr_closure_trace_v); + scr_box_set_ref(w->caps[0], cb); + w->caps[1] = scr_box_new_obj(scr_stream_ecb_retain, scr_stream_ecb_release, scr_stream_ecb_trace); + scr_box_set_ref(w->caps[1], (void *)fn); + return w; +} +#else +#define SCR_WCB_CALL(cb, err) ((void (*)(ScrClosure *))(cb)->fn)(cb) +#define SCR_WCB_CALL_ERROF(cb, st) SCR_WCB_CALL(cb, NULL) +#endif + /* errorBuffer: once destroyed, the buffered writes' callbacks and the * end(cb) callbacks run (Node passes them ERR_STREAM_DESTROYED or the * stream's error) — unless a write is in flight, whose completion runs @@ -2184,7 +2237,7 @@ static void scr_stream_error_buffer(ScrStream *s) { scr_stream_wchunk_release(st, e.chunk); if (e.enc) scr_dyn_release(e.enc); if (e.cb) { - ((void (*)(ScrClosure *))e.cb->fn)(e.cb); + SCR_WCB_CALL_ERROF(e.cb, st); scr_closure_release(e.cb); } } @@ -2227,7 +2280,7 @@ static void scr_stream_after_write(ScrStream *s, ScrClosure *cb, size_t count) { if (cb) { /* afterWrite: the callback once per write the tick completes */ for (size_t i = 0; i < count; i++) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, NULL); if (scr_exc_pending()) break; } scr_closure_release(cb); @@ -7129,7 +7182,13 @@ static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed* ScrError *e = st->has_w && !st->destroyed ? scr_stream_mkerr("ERR_STREAM_WRITE_AFTER_END", "write after end") : scr_stream_mkerr("ERR_STREAM_DESTROYED", "Cannot call write after a stream was destroyed"); - if (cb) scr_st_tick(s, SCR_ST_WCB_ERR, NULL, cb); + if (cb) { +#ifdef SCR_STREAM_WRITE_ERRORS + scr_st_tick(s, SCR_ST_WCB_ERR, scr_error_retain(e), cb); +#else + scr_st_tick(s, SCR_ST_WCB_ERR, NULL, cb); +#endif + } scr_stream_error_or_destroy(s, e); scr_error_release(e); return false; @@ -7381,7 +7440,7 @@ void scr_stream_write_done(ScrStream *s, ScrError *err /*moves*/) { return; } if (ucb) { - ((void (*)(ScrClosure *))ucb->fn)(ucb); + SCR_WCB_CALL(ucb, err); scr_closure_release(ucb); } if (!scr_exc_pending()) scr_stream_error_or_destroy(s, err); @@ -7970,8 +8029,12 @@ static void scr_stream_run_tick(ScrStreamTick *t) { scr_stream_after_write(s, cb, t->count); } else { if (cb) { + /* Node v24 afterWrite runs `cb(null)` even for a destroyed + * stream (the destroy that landed after this tick was queued + * does not poison the completions it already owns), then + * errorBuffer. */ for (size_t i = 0; i < t->count; i++) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, NULL); if (scr_exc_pending()) break; } scr_closure_release(cb); @@ -8065,7 +8128,7 @@ static void scr_stream_run_tick(ScrStreamTick *t) { ScrClosure *cb = t->cb; t->cb = NULL; if (cb) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, t->err); scr_closure_release(cb); } break; @@ -8074,7 +8137,7 @@ static void scr_stream_run_tick(ScrStreamTick *t) { ScrClosure *cb = t->cb; t->cb = NULL; if (cb) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, t->err); scr_closure_release(cb); } if (!scr_exc_pending()) scr_stream_error_or_destroy(s, t->err); diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index 578712df2..960193261 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -420,6 +420,14 @@ ScrStream *scr_stream_end(ScrStream *s, ScrBytes *chunk_b, ScrStr *chunk_s, void scr_stream_cork(ScrStream *s); void scr_stream_uncork(ScrStream *s); +#ifdef SCR_STREAM_WRITE_ERRORS +/* An error-first write callback: wraps the user's zero-argument closure + * (which MOVES) so the compiler-built adapter (an ScrFsRenameFn, + * fn(cb, err-or-NULL)) receives the error this write completion carries + * (NULL is Node's null). */ +ScrClosure *scr_stream_ecb_wrap(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrError *)); +#endif + /* Shared lifecycle. destroy's err is BORROWED (may be NULL). */ ScrStream *scr_stream_destroy(ScrStream *s, ScrError *err); /* recv +1 */ /* Readable async-iterator return(): destroy with Node's AbortError payload; diff --git a/tests/corpus/writable-error-callbacks.ts b/tests/corpus/writable-error-callbacks.ts new file mode 100644 index 000000000..af0c66a4b --- /dev/null +++ b/tests/corpus/writable-error-callbacks.ts @@ -0,0 +1,53 @@ +// Writable.write completion callbacks that take the error argument: null +// after a successful write, the _write failure, ERR_STREAM_WRITE_AFTER_END +// after end(), and ERR_STREAM_DESTROYED after destroy(). Node orders the +// callback and the 'error' event through its writable state machine; this +// program prints both. +import { Writable } from "node:stream"; + +function show(label: string): (error?: Error | null) => void { + return (error) => { + const code = error ? (error as NodeJS.ErrnoException).code : undefined; + console.log( + label, + error === null ? "null" : error === undefined ? "undefined" : `${code ?? ""} ${error.message}`, + ); + }; +} + +const sink = new Writable({ + write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(chunk.toString() === "bad" ? new Error("rejected") : null); + }, +}); +sink.on("error", (error) => console.log("error event", error.message)); +sink.write("good", show("good")); +sink.write("bad", show("bad")); + +const ended = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + }, +}); +ended.on("error", (error) => + console.log("ended error event", (error as NodeJS.ErrnoException).code), +); +ended.write("first", show("first")); +ended.end(); +ended.write("late", show("late")); + +const destroyed = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + }, +}); +destroyed.destroy(); +destroyed.write("after destroy", show("destroyed")); + +// A callback declared without parameters keeps its current lowering. +const plain = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: () => void): void { + callback(); + }, +}); +plain.write("x", () => console.log("plain done")); diff --git a/tests/harness/corpus-costs/writable-error-callbacks.ts.json b/tests/harness/corpus-costs/writable-error-callbacks.ts.json new file mode 100644 index 000000000..e652520cd --- /dev/null +++ b/tests/harness/corpus-costs/writable-error-callbacks.ts.json @@ -0,0 +1 @@ +{ "plain": 4567, "san": 86151 } From bd06b809f1276c4fd11138db4fdb331ffd6570ac Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:51:28 -0700 Subject: [PATCH 087/193] Answer drain probes and drain listeners on the process streams (W1) Under the synchronous output model nothing is ever queued: Node emits 'drain' only after a write() returned false, and these writes never return false. The process streams therefore describe a stream whose writes complete synchronously, and the members a flush idiom probes are constants: - writableHighWaterMark is 65536 (Node's default for a pipe, a file and a TTY); - writableNeedDrain is false; - writableLength stays 0. `on`/`once`/`addListener`/`off`/`removeListener`/`prependListener`/ `prependOnceListener` of "drain" evaluate their receiver and listener and register nothing: the event cannot fire, and the listener table is not observable (listenerCount/listeners/eventNames on the process streams stay refused). `events.once(process.stdout | process.stderr, "drain")` lowers to `events.pendingForever`, a fresh promise that never settles, which is Node's behavior for a stream no write asked to drain; it holds no loop handle, so a program awaiting it still exits. The fallback declarations gain writableHighWaterMark, writableNeedDrain and the event methods, so typed programs can ask. Corpus: `tests/corpus/process-stream-drain.ts`; the diagnostics fixture `process-stream-listener-count.ts` pins that reading the listener table stays refused. Co-Authored-By: Claude Opus 5.5 --- .../console-and-process-streams.mdx | 1 + .../ambient/scriptc-node-fallback.d.ts | 12 +++ .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../src/frontend/lowering/builtins/process.ts | 84 ++++++++++++++++++- .../frontend/lowering/lower-event-emitter.ts | 20 +++++ .../tests/corpus/process-stream-drain.ts.json | 6 ++ .../process-stream-listener-count.ts.json | 18 ++++ tests/corpus/process-stream-drain.ts | 46 ++++++++++ .../process-stream-listener-count.ts | 5 ++ .../process-stream-listener-count.ts.txt | 8 ++ .../corpus-costs/process-stream-drain.ts.json | 1 + 11 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json create mode 100644 tests/corpus/process-stream-drain.ts create mode 100644 tests/diagnostics/process-stream-listener-count.ts create mode 100644 tests/harness/__snapshots__/process-stream-listener-count.ts.txt create mode 100644 tests/harness/corpus-costs/process-stream-drain.ts.json diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index a877512c1..8173d037e 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -16,6 +16,7 @@ JavaScript and TypeScript can store `process.stdin`, `stdout`, and `stderr`, rep A replaced `write` method does not receive console's error-callback argument. Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode. Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled. +Because no write is ever queued, `writableLength` is 0, `writableNeedDrain` is false, `writableHighWaterMark` is 65536, and `'drain'` never fires, so drain listeners register silently and `events.once(process.stdout, "drain")` stays pending, as it does in Node when no write returned false. Stored streams do not support piping, encoding changes, arbitrary properties, or other events. A TypeScript `NodeJS.WritableStream` parameter that only receives `process.stdout` or `process.stderr` supports `write(string)`. When other streams flow into the same parameter, the process streams join them as `Writable` instances with the full writable surface. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..3cce98267 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1747,6 +1747,18 @@ declare module "tty" { /* The bytes queued behind the last write: the compiled binary's fd * 1/2 writes are synchronous, so it is always 0. */ readonly writableLength: number; + /* Node's default for the process streams (65536 for a pipe, a file and a + * TTY): a constant, because nothing is ever queued. */ + readonly writableHighWaterMark: number; + /* Always false: a synchronous write never returns false, so no 'drain' + * is ever pending. */ + readonly writableNeedDrain: boolean; + /* 'drain' and the other events by name. Node returns the stream. */ + on(event: string, listener: (...args: any[]) => void): this; + once(event: string, listener: (...args: any[]) => void): this; + addListener(event: string, listener: (...args: any[]) => void): this; + off(event: string, listener: (...args: any[]) => void): this; + removeListener(event: string, listener: (...args: any[]) => void): this; getWindowSize(): [number, number]; } export function isatty(fd: number): boolean; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9ed4e3a18..754b5eb33 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -381,6 +381,9 @@ export const LIB_FN_SYMS: Record = { "process.stdio": "scr_process_stdio", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", + // A promise that never settles (events.once of an event a synchronous + // stream never emits); a fresh pending promise is its owned result. + "events.pendingForever": "scr_promise_new", "date.now": "scr_date_now", "date.newNow": "scr_date_now", "date.newMs": "scr_date_new_ms", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 065183a69..87fd66412 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -442,7 +442,9 @@ export function lowerProcessStreamProperty( member !== "isTTY" && member !== "columns" && member !== "rows" && - member !== "writableLength" + member !== "writableLength" && + member !== "writableHighWaterMark" && + member !== "writableNeedDrain" ) return null; let recv: ts.Expression = expr.expression; @@ -476,19 +478,31 @@ export function lowerProcessStreamProperty( // nothing is ever queued and Node's number is always 0. The other // members stay keyed reads — the runtime stream answers their live // values (isatty columns/rows, stdin readableLength). - if (member === "writableLength") { + if ( + member === "writableLength" || + member === "writableHighWaterMark" || + member === "writableNeedDrain" + ) { // The receiver still EVALUATES: a call or property read that hands // back the stream runs its own side effects — and throws where Node // throws — before the constant answers, exactly as the read of the // member would. A pure receiver (the alias above) folds away. - const answer: IrExpr = { kind: "numLit", value: 0, type: F64, loc: locOf(expr) }; + const answer: IrExpr = + member === "writableNeedDrain" + ? { kind: "boolLit", value: false, type: BOOL, loc: locOf(expr) } + : { + kind: "numLit", + value: member === "writableLength" ? 0 : 65536, + type: F64, + loc: locOf(expr), + }; const effect = lowerer.lowerExpr(expr.expression); if (isSafeToDiscard(effect)) return answer; return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: effect, loc: locOf(expr.expression) }], result: answer, - type: F64, + type: answer.type, loc: locOf(expr), }; } @@ -520,6 +534,16 @@ export function lowerProcessStreamProperty( // true for the same reason). Backpressure probes read 0 and stay // correct; the write itself never defers. if (member === "writableLength") return { kind: "numLit", value: 0, type: F64, loc }; + // Node's default high-water mark for the process streams, and the + // drain flag a synchronous write never sets. + if (member === "writableHighWaterMark") { + if (stream === "stdin") return null; + return { kind: "numLit", value: 65536, type: F64, loc }; + } + if (member === "writableNeedDrain") { + if (stream === "stdin") return null; + return { kind: "boolLit", value: false, type: BOOL, loc }; + } if (member === "isTTY") { const type = lowerer.withUndefinedArm(BOOL); return { @@ -1000,6 +1024,58 @@ export function lowerProcessMethodCall( } return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc }; } + // process.stdout/stderr.on("drain", fn) and its spellings: Node emits + // 'drain' only after a write() returned false, and these writes are + // synchronous (they never return false), so the listener can never run. + // Registering it is therefore observably a no-op: the receiver and the + // listener are evaluated for their effects and nothing is retained. + // listenerCount/listeners/eventNames on the process streams stay + // refused, so the missing registration cannot be observed. + if ( + [ + "on", + "once", + "off", + "addListener", + "removeListener", + "prependListener", + "prependOnceListener", + ].includes(access.name.text) && + ts.isPropertyAccessExpression(access.expression) && + call.arguments.nodes.length === 2 && + !call.arguments.nodes.some(ts.isSpreadElement) + ) { + const stream = lowerer.stdlibGlobalMember(access.expression, "process"); + const evT = lowerer.typeOf(call.arguments.nodes[0]!); + if ( + (stream === "stdout" || stream === "stderr") && + evT.isStringLiteralType() && + evT.value === "drain" + ) { + const loc = locOf(call); + const parent = call.parent; + const discarded = + ts.isExpressionStatement(parent) || (ts.isArrowFunction(parent) && parent.body === call); + if (!discarded) { + lowerer.unsupported( + "SC1090", + call, + `using the result of process.${stream}.${access.name.text}("drain", ...) (register it as its own statement)`, + ); + } + const stmts: IrStmt[] = []; + for (const arg of call.arguments.nodes) { + const value = lowerer.lowerExprExpecting(arg, DYN); + if (!isSafeToDiscard(value)) stmts.push({ kind: "exprStmt", expr: value, loc: value.loc }); + } + // The call's value is discarded (statement position or a void arrow + // body), so the lowering is the evaluated arguments alone; a bare + // unitLit has no standalone IR value, so the no-op result is the + // dynamic undefined a discarded call leaves. + const result: IrExpr = dynUndefinedExpr(loc); + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: DYN, loc }; + } + } // process.stdout.on("resize", cb) (and once/off/removeListener/ // addListener, stderr too): the terminal resize event, registered on the // runtime's process stream object (a SIGWINCH listener that emits when a diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index f44a3d6d8..be6ee9c75 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2532,6 +2532,26 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc: const arrayT = arrayOf(DYN); const resultT: IrType = { kind: "promise", inner: arrayT }; let receiver = lowerer.lowerExpr(args[0]!); + // events.once(process.stdout, "drain"): the next 'drain' of a stream + // whose writes are synchronous never comes (Node emits it only after a + // write returned false), so the promise stays pending. A pending promise + // holds nothing: the process exits when the loop is empty, as in Node. + { + let target = args[0]!; + while (ts.isParenthesizedExpression(target)) target = target.expression; + const stream = ts.isPropertyAccessExpression(target) + ? lowerer.stdlibGlobalMember(target, "process") + : null; + const nameT = lowerer.typeOf(args[1]!); + if ( + (stream === "stdout" || stream === "stderr") && + nameT.isStringLiteralType() && + nameT.value === "drain" && + args.length === 2 + ) { + return { kind: "libCall", fn: "events.pendingForever", args: [], type: resultT, loc }; + } + } // A checked-dynamic NodeJS stream slot (dyn-streams.ts): a native stream // unboxes by identity; any other value throws the not-supported Error. if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json new file mode 100644 index 000000000..95107f403 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-stream-drain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json new file mode 100644 index 000000000..3493be335 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/process-stream-listener-count.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Property 'listenerCount' does not exist on type 'WriteStream'.", + "loc": { + "file": "/tests/diagnostics/process-stream-listener-count.ts", + "start": 269, + "end": 282 + }, + "typeWorld": true, + "hint": "the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — 'listenerCount' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint" + } + ] +} diff --git a/tests/corpus/process-stream-drain.ts b/tests/corpus/process-stream-drain.ts new file mode 100644 index 000000000..aa711edca --- /dev/null +++ b/tests/corpus/process-stream-drain.ts @@ -0,0 +1,46 @@ +// process.stdout/stderr backpressure probes under the synchronous stream +// model: every write reaches the descriptor before write() returns, so +// nothing is ever queued. writableLength is 0, writableNeedDrain is false, +// write() returns true, and 'drain' (emitted only after a write returned +// false) never fires: drain listeners register and stay silent, and a +// promise of the next drain never settles without holding the process. +import { once } from "node:events"; + +console.log("hwm", process.stdout.writableHighWaterMark, process.stderr.writableHighWaterMark); +console.log("needDrain", process.stdout.writableNeedDrain, process.stderr.writableNeedDrain); +console.log("length", process.stdout.writableLength, process.stderr.writableLength); + +let drained = 0; +const onDrain = (): void => { + drained++; +}; +process.stdout.on("drain", onDrain); +process.stdout.once("drain", () => { + drained++; +}); +process.stderr.addListener("drain", onDrain); +process.stdout.off("drain", onDrain); +process.stderr.removeListener("drain", onDrain); + +const ok = process.stdout.write("chunk\n"); +console.log("write returned", ok); + +// The flush idiom: wait for drain only when bytes are queued or the +// write asked for it. +async function flush(): Promise { + if (process.stdout.writableLength > 0) { + await new Promise((resolve) => process.stdout.once("drain", resolve)); + } + if (!process.stdout.write("again\n")) await once(process.stdout, "drain"); + console.log("flushed"); +} +void flush(); + +// A drain that cannot come: the promise stays pending, and the process +// exits once the loop is empty. +void (async () => { + await once(process.stderr, "drain"); + console.log("never"); +})(); + +setTimeout(() => console.log("drained", drained), 5); diff --git a/tests/diagnostics/process-stream-listener-count.ts b/tests/diagnostics/process-stream-listener-count.ts new file mode 100644 index 000000000..ca3ce5693 --- /dev/null +++ b/tests/diagnostics/process-stream-listener-count.ts @@ -0,0 +1,5 @@ +// Drain registration on the process streams lowers to nothing because a +// synchronous write never asks for a drain; the listener table it would +// fill is not observable, so reading it stays refused. +process.stdout.once("drain", () => {}); +console.log(process.stdout.listenerCount("drain")); diff --git a/tests/harness/__snapshots__/process-stream-listener-count.ts.txt b/tests/harness/__snapshots__/process-stream-listener-count.ts.txt new file mode 100644 index 000000000..3bc0eb156 --- /dev/null +++ b/tests/harness/__snapshots__/process-stream-listener-count.ts.txt @@ -0,0 +1,8 @@ +process-stream-listener-count.ts:5:28 - error SC0001: Property 'listenerCount' does not exist on type 'WriteStream'. + + 4 | process.stdout.once("drain", () => {}); + 5 | console.log(process.stdout.listenerCount("drain")); + | ^~~~~~~~~~~~~ + 6 | + + hint: the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — 'listenerCount' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint \ No newline at end of file diff --git a/tests/harness/corpus-costs/process-stream-drain.ts.json b/tests/harness/corpus-costs/process-stream-drain.ts.json new file mode 100644 index 000000000..283af8f7c --- /dev/null +++ b/tests/harness/corpus-costs/process-stream-drain.ts.json @@ -0,0 +1 @@ +{ "plain": 5912, "san": 124235 } From 7be1a990c79deff74fb81e5904322ba92daeef0f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:51:32 -0700 Subject: [PATCH 088/193] Write run-time-typed chunks to the process streams (W2) `process.stdout.write(data)`/`process.stderr.write(data)` with a chunk whose type is only known at run time (`any`, or a `string | Uint8Array` union) was refused. Lower the one-argument form to the new `process.stdioWriteDyn` libCall, and factor the existing stdio write method's dispatch into `scr_stdio_write_call` so both share Node's Writable validation: a string or a Uint8Array/Buffer writes its bytes, null throws ERR_STREAM_NULL_VALUES, and any other value throws ERR_INVALID_ARG_TYPE, all before anything is written. `scr_stdio_write_call` keeps its exact statements, so it is only the call sites that change; programs that contain a JS stdio value and link the events unit may differ (their write method's bytes). Corpus: `tests/corpus/process-write-dynamic-chunks.ts` covers strings, Buffer, Uint8Array, a string | Uint8Array union, a JSON field, and the four invalid chunks with their codes and messages. Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-internal.json | 8 ++ internal/compatibility/static-support.json | 2 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/llvm/reference-writes.ts | 1 + .../src/frontend/lowering/builtins/process.ts | 18 ++++ packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 7 ++ packages/compiler/src/ir/runtime-features.ts | 1 + .../process-write-dynamic-chunks.ts.json | 6 ++ packages/runtime/src/scr_blob.h | 4 + packages/runtime/src/scr_events.c | 92 ++++++++++++------- tests/corpus/process-write-dynamic-chunks.ts | 34 +++++++ .../process-write-dynamic-chunks.ts.json | 1 + 13 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json create mode 100644 tests/corpus/process-write-dynamic-chunks.ts create mode 100644 tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 928bea3bb..14a5a4d6a 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79679,6 +79679,7 @@ "tests/corpus/process-builtin-values.js", "tests/corpus/process-hrtime.js", "tests/corpus/process-named-signals.cjs", + "tests/corpus/process-stream-drain.ts", "tests/corpus/process-uncaught-handler-throws.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", @@ -79686,6 +79687,7 @@ "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-versions-storage.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdio-write-overrides.mjs", @@ -82049,6 +82051,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82092,6 +82096,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82135,6 +82141,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 96e4418db..5b4095f9a 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -314,7 +314,7 @@ { "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] }, { "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] }, { "chapter": "errors", "signatures": ["`stack` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/error-stack-frames.ts", "tests/corpus/error-stack-limit.ts"] }, - { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts"] }, + { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/corpus/process-stream-drain.ts", "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts"] }, { "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] }, { "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] }, { "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 754b5eb33..e7f918fdf 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -379,6 +379,7 @@ export const LIB_FN_SYMS: Record = { "process.envPairs": "scr_env_pairs", "process.stdoutWrite": "scr_process_stdout_write", "process.stdio": "scr_process_stdio", + "process.stdioWriteDyn": "scr_process_stdio_write_dyn", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", // A promise that never settles (events.once of an event a synchronous diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 56379a443..7ef02a978 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -102,6 +102,7 @@ const INERT_LIB_CALLS = new Set([ "json.parse", "process.stderrWrite", "process.stdoutWrite", + "process.stdioWriteDyn", "process.envGet", "process.envPairs", "regex.groupEquals", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 87fd66412..e5c9bd5af 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -1357,6 +1357,24 @@ export function lowerProcessMethodCall( if (data.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(call))) { data = { kind: "dynCheck", value: data, type: STRING, loc }; } + // A chunk known only at run time (`any`, or a string | Uint8Array + // union): the runtime dispatches on the value — a string or a + // Uint8Array/Buffer writes, null throws ERR_STREAM_NULL_VALUES, any + // other value throws ERR_INVALID_ARG_TYPE, as Node's Writable.write + // does before writing anything. Only the one-argument form. + if ( + args.length === 1 && + (data.type.kind === "dyn" || data.type.kind === "union") && + (data.type.kind === "dyn" || lowerer.dynConvertible(data.type)) + ) { + return { + kind: "libCall", + fn: "process.stdioWriteDyn", + args: [numLit(stream === "stdout" ? 1 : 2, loc), lowerer.coerceToExpected(data, DYN)], + type: BOOL, + loc, + }; + } const isBytes = data.type.kind === "bytes" && data.type.elem === "u8"; if (!isBytes && data.type.kind !== "string") { lowerer.noLowering( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 114231730..85005cfc2 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -107,6 +107,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "ffi.argument", "ffi.memoryModule", "process.stdoutWrite", + "process.stdioWriteDyn", "process.stderrWrite", "process.stdoutWriteBytes", "process.stderrWriteBytes", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 55596d087..d8d3ce999 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3668,6 +3668,9 @@ function buildLibFnSignatures() { * never throws. */ "process.stdoutWrite": { argTypes: [STRING], result: BOOL }, "process.stdio": { argTypes: [F64], result: DYN }, + /** process.stdout/stderr.write(chunk) with a run-time chunk: Node's + * Writable validation, then a synchronous write. */ + "process.stdioWriteDyn": { argTypes: [F64, DYN], result: BOOL }, "process.stderrWrite": { argTypes: [STRING], result: BOOL }, // error.new's result and the receiver slots are builtin-error classes — // program-dependent object types, checked in the libCall case. @@ -5341,6 +5344,10 @@ function buildLibFnSignatures() { /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ "process.isTTY": { argTypes: [F64], result: BOOL }, + /** A promise that never settles (events.once of an event that cannot + * occur, e.g. 'drain' on a synchronous process stream). The result is + * events.once's own Promise type. */ + "events.pendingForever": { argTypes: [], result: { kind: "promise", inner: arrayOf(DYN) } }, // Like process.envGet: the result is the module's interned // `number | undefined` union — checked by arms in the libCall case. /** Terminal width over an fd literal (1/2 — process.stdout/stderr diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 93b78db14..f922204d0 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -647,6 +647,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.onExit", "process.onSignal", "process.stdio", + "process.stdioWriteDyn", "stdin.nextChunk", "stdin.onData", "stdin.onEnd", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json new file mode 100644 index 000000000..0558bbdd6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-write-dynamic-chunks.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 7c7893322..53d2d2228 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -68,6 +68,10 @@ ScrDyn *scr_console_new(double out_fd, double err_fd, bool ignore_errors); void scr_console_bind(ScrDyn *console, double which, ScrStream *stream); void scr_console_inst_write(ScrDyn *console, ScrStr *text, bool to_stderr); ScrDyn *scr_process_stdio(double fd); +/* process.stdout/stderr.write(chunk) with a run-time chunk: Node's Writable + * validation, then a synchronous write. false with an exception pending when + * the chunk is invalid. */ +bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk); bool scr_dyn_process_stdio_is(const ScrDyn *value); double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path); diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 9de2c6ce2..8794c270c 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1311,6 +1311,52 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r } #endif + +/* stream.write(chunk[, encoding][, callback]) on a process output stream: + * Node's Writable.write validation, then a synchronous descriptor write. + * Returns +1 (true/false) or NULL with an exception pending. */ +static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *arg = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *result = NULL; + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); + if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } + if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + return NULL; + } + if (arg->kind == SCR_DYN_NULL) { + const char *m = "May not write null values to stream"; + scr_throw_error_msg_code(SCR_ERR_TYPE, m, strlen(m), "ERR_STREAM_NULL_VALUES"); + return NULL; + } + if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem != SCR_BYTES_U8) + return scr_stdio_refusal("write of a typed array other than Uint8Array"); + ScrBytes *bytes = NULL; + if (arg->kind == SCR_DYN_STR) { + if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { + return scr_stdio_refusal("write encoding"); + } + ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); + bytes = scr_bytes_from_str(arg->v.str, enc); + scr_str_release(enc); + } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { + bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); + } else { + scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); + return NULL; + } + if (!bytes) return NULL; + int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); + scr_bytes_release(bytes); + if (error) { + scr_stdio_queue_write_error(stream, callback, error); + return scr_dyn_new_bool(false); + } + if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); + return scr_dyn_new_bool(true); +} + static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); ScrDyn *self = scr_dyn_this_get(); @@ -1328,38 +1374,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_dyn_arr_push(result, columns); scr_dyn_arr_push(result, rows); } else if (method == STDIO_WRITE && stream->fd != 0) { - ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); - ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); - if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } - if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { - scr_dyn_arg_type_fail("cb", "of type function", callback); - goto done; - } - ScrBytes *bytes = NULL; - if (arg->kind == SCR_DYN_STR) { - if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { - result = scr_stdio_refusal("write encoding"); - goto done; - } - ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); - bytes = scr_bytes_from_str(arg->v.str, enc); - scr_str_release(enc); - } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { - bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); - } else { - scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); - goto done; - } - if (!bytes) goto done; - int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); - scr_bytes_release(bytes); - if (error) { - scr_stdio_queue_write_error(stream, callback, error); - result = scr_dyn_new_bool(false); - goto done; - } - if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); - result = scr_dyn_new_bool(true); + result = scr_stdio_write_call(stream, args, argc); } else if (method == STDIO_ON || method == STDIO_ONCE || method == STDIO_OFF) { ScrDyn *listener = argc > 1 ? args[1] : scr_dyn_undefined(); if (listener->kind != SCR_DYN_FUNC) { @@ -1597,6 +1612,19 @@ double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path) { return stream ? stream->fd : 0; } +/* process.stdout/stderr.write(chunk) with a run-time chunk: the stream + * method's own validation and write, without creating the stream value + * or installing the write hook. False with an exception pending when the + * chunk is invalid. */ +bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk) { + ScrStdio *stream = &scr_stdio_streams[fd == 2 ? 2 : 1]; + ScrDyn *result = scr_stdio_write_call(stream, &chunk, 1); + if (!result) return false; + bool accepted = scr_dyn_truthy(result); + scr_dyn_release(result); + return accepted; +} + /* ── the process 'exit' event ───────────────────────────────────────── * Listeners run SYNCHRONOUSLY when the process exits: at the end of a * normal run (the unit's atexit, registered by scr_events_install after diff --git a/tests/corpus/process-write-dynamic-chunks.ts b/tests/corpus/process-write-dynamic-chunks.ts new file mode 100644 index 000000000..4356943b2 --- /dev/null +++ b/tests/corpus/process-write-dynamic-chunks.ts @@ -0,0 +1,34 @@ +// process.stdout/stderr.write of chunks whose type is only known at run +// time: `any` values (a child's 'data' chunk, a JSON field) and +// string | Uint8Array unions. Strings and Uint8Array/Buffer values write +// their bytes; null throws ERR_STREAM_NULL_VALUES and any other value +// throws ERR_INVALID_ARG_TYPE, both TypeErrors raised synchronously before +// anything is written. + +function forward(data: any): boolean { + return process.stdout.write(data); +} + +function pick(useBytes: boolean): string | Uint8Array { + return useBytes ? new Uint8Array([98, 121, 116, 101, 115, 10]) : "text\n"; +} + +console.log("string", forward("any string\n")); +console.log("buffer", forward(Buffer.from("any buffer\n"))); +console.log("u8", forward(new TextEncoder().encode("any u8\n"))); +process.stdout.write(pick(false)); +process.stdout.write(pick(true)); +process.stderr.write(pick(false)); +const parsed: any = JSON.parse('{"chunk":"from json\\n","n":42}'); +forward(parsed.chunk); + +for (const bad of [parsed.n, null, undefined, { a: 1 }]) { + try { + forward(bad); + console.log("no throw"); + } catch (error) { + if (error instanceof TypeError) { + console.log((error as NodeJS.ErrnoException).code, error.message); + } + } +} diff --git a/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json b/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json new file mode 100644 index 000000000..b83881cc0 --- /dev/null +++ b/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json @@ -0,0 +1 @@ +{ "plain": 4567, "san": 86527 } From e386da4aa412a74dbb19b870417a5753fe524c0f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:50:25 -0700 Subject: [PATCH 089/193] Gate the typed stdio write slot out of the default events object The typed write slot, the drain probes and the run-time-typed chunk write all live in scr_events.c, a unit every program links. Unconditionally they changed the default object, so executables of programs that never touch the write slot changed byte for byte (three corpus programs differed runtime-only). Typed programs must stay byte-identical. Keep the default object exactly as it was and put the new code behind SCR_STDIO_WRITE_SLOT, the way SCR_STREAM_WRITE_ERRORS gates the stream unit: the shared write body, the run-time-typed chunk entry point and Node's anonymous write function exist only in that build, and scr_stdio_method keeps its own body everywhere else. The lowering sites that reach the slot (the write method as a value, its replacement, a patched program's typed writes) now emit a distinct marker lib call, process.stdioSlot, which is the same scr_process_stdio at run time. The feature scan turns it, together with process.stdioWriteDyn, into the stdioWriteSlot runtime feature, which selects the matching runtime pack variant and defines SCR_STDIO_WRITE_SLOT for the executable. The pack matrix enumerates the combinations with stdioExtended and nodeIsland explicitly, so each existing variant keeps its defines and its object. Both new lib fns join USES_TIMERS_LIB_FNS: the write callbacks they queue run from a nextTick, and a program whose only stdio use is the write slot would otherwise drop the callback. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 6 +++ .../src/backend/native-link-info-core.ts | 3 ++ .../compiler/src/backend/native/contracts.ts | 3 ++ .../compiler/src/backend/native/executable.ts | 2 + .../compiler/src/backend/runtime-pack-core.ts | 4 ++ .../src/executable/executable-cache.ts | 2 + .../src/frontend/lowering/builtins/process.ts | 4 +- .../src/frontend/lowering/lower-stmts.ts | 2 +- packages/compiler/src/index.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 ++ packages/compiler/src/ir/runtime-features.ts | 10 ++++ .../compiler/src/native/sanitizer.test.ts | 1 + .../runtime-darwin-arm64/scripts/build.mjs | 1 + .../runtime-pack-matrix.mjs | 23 +++++++++ .../runtime-pack-common/scripts/build.mjs | 1 + .../test/runtime-pack-matrix.test.ts | 32 ++++++++++++ packages/runtime/src/scr_events.c | 50 ++++++++++++++++++- 18 files changed, 147 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f333aff7e..bbf3cdfff 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -21,6 +21,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), + ...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index e7f918fdf..694f8e8f0 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -379,6 +379,7 @@ export const LIB_FN_SYMS: Record = { "process.envPairs": "scr_env_pairs", "process.stdoutWrite": "scr_process_stdout_write", "process.stdio": "scr_process_stdio", + "process.stdioSlot": "scr_process_stdio", "process.stdioWriteDyn": "scr_process_stdio_write_dyn", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", @@ -1869,6 +1870,11 @@ export const USES_TIMERS_LIB_FNS = new Set([ "worker.receiveMessageOnPort", "worker.broadcastChannel", "process.stdio", + // The typed write slot and the run-time-typed chunk write keep the loop + // live for the write callbacks they queue: both reach the same stream + // object as process.stdio and call back from a nextTick. + "process.stdioSlot", + "process.stdioWriteDyn", "fetch.start", "fetch.input", "fetch.function", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 54aa87b4d..43d4efa22 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -37,6 +37,9 @@ export interface NativeLinkFeatures { /** A write completion callback takes the error argument: links the * SCR_STREAM_WRITE_ERRORS build of the stream unit. */ streamWriteErrors?: boolean; + /** The program reaches the process output streams' write slot: links the + * SCR_STDIO_WRITE_SLOT build of the events unit. */ + stdioWriteSlot?: boolean; /** Calls into Node.js (@scriptc/node): scr_node_island.c and the * SCR_NODE_ISLANDS variant of the event loop unit. */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index a58d2310f..7a332e11f 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -164,6 +164,9 @@ export interface CcOptions { /** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each * write completion callback receives (error-taking write callbacks). */ streamWriteErrors?: boolean; + /** SCR_STDIO_WRITE_SLOT: the events unit carries the typed write slot + * (the write method as a value, its replacement, run-time-typed chunks). */ + stdioWriteSlot?: boolean; /** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and * the event loop with SCR_NODE_ISLANDS (its helper slot). */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 4d099a6d9..3ec34ca7c 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -412,6 +412,8 @@ const RUNTIME_DEFINES: readonly { // scr_stream.c's write-callback error publication, which the compiler's // error-first write adapter reads. { define: "SCR_STREAM_WRITE_ERRORS", when: (opts) => opts.streamWriteErrors }, + // scr_events.c's typed process output write slot. + { define: "SCR_STDIO_WRITE_SLOT", when: (opts) => opts.stdioWriteSlot }, // scr_async.c's native promise reactions, which the web streams unit uses, // scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes, // and scr_fetch.c's bodies of its streams. diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index cd22be057..a26e5e912 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -104,6 +104,9 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { /** The program's write completion callbacks take the error argument: * the SCR_STREAM_WRITE_ERRORS build of the stream unit. */ streamWriteErrors: boolean; + /** The program reaches the process output streams' write slot: the + * SCR_STDIO_WRITE_SLOT build of the events unit. */ + stdioWriteSlot: boolean; nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; @@ -316,6 +319,7 @@ export function effectiveRuntimeFeatures( warningReplace: features.warningReplace === true, stdioExtended: features.stdioExtended === true, streamWriteErrors: features.streamWriteErrors === true, + stdioWriteSlot: features.stdioWriteSlot === true, nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 36965eb89..702adc403 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -67,6 +67,8 @@ export interface EarlyExecutableNativeFeatures { stdioExtended?: boolean; /** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */ streamWriteErrors?: boolean; + /** The process output write slot: SCR_STDIO_WRITE_SLOT. */ + stdioWriteSlot?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index e5c9bd5af..3211994b9 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -416,7 +416,7 @@ export function lowerProcessStreamProperty( kind: "dynKeyGet", value: { kind: "libCall", - fn: "process.stdio", + fn: "process.stdioSlot", args: [numLit(direct === "stdout" ? 1 : 2, loc)], type: DYN, loc, @@ -1242,7 +1242,7 @@ export function lowerProcessMethodCall( if (programPatchesStdio(lowerer) && !args.some(ts.isSpreadElement)) { const handle: IrExpr = { kind: "libCall", - fn: "process.stdio", + fn: "process.stdioSlot", args: [numLit(stream === "stdout" ? 1 : 2, loc)], type: DYN, loc, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index cfea92522..c452beb94 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -9214,7 +9214,7 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm args: [ { kind: "libCall", - fn: "process.stdio", + fn: "process.stdioSlot", args: [numLit(stream === "stdout" ? 1 : 2, loc)], type: DYN, loc, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index ecd6734a8..759871465 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -701,6 +701,7 @@ async function compileExecutableNative( ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), + ...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index d8d3ce999..60fa80788 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3668,6 +3668,11 @@ function buildLibFnSignatures() { * never throws. */ "process.stdoutWrite": { argTypes: [STRING], result: BOOL }, "process.stdio": { argTypes: [F64], result: DYN }, + /** The same stream object, reached by the typed write-slot lowering + * (the write method as a value, its replacement, a patched program's + * typed writes): identical at run time, and the link switch for the + * SCR_STDIO_WRITE_SLOT build of the events unit. */ + "process.stdioSlot": { argTypes: [F64], result: DYN }, /** process.stdout/stderr.write(chunk) with a run-time chunk: Node's * Writable validation, then a synchronous write. */ "process.stdioWriteDyn": { argTypes: [F64, DYN], result: BOOL }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index f922204d0..43780b711 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -647,6 +647,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.onExit", "process.onSignal", "process.stdio", + "process.stdioSlot", "process.stdioWriteDyn", "stdin.nextChunk", "stdin.onData", @@ -1190,6 +1191,14 @@ const RUNTIME_FEATURE_RULES = { * through writable.writeEcb: the SCR_STREAM_WRITE_ERRORS stream unit * publishes the error the adapter delivers. */ streamWriteErrors: rule({ fns: ["writable.writeEcb"] }), + /** The program reaches the process output streams' write slot — the + * write method as a value (process.stdioSlot), its replacement, a patched + * program's typed writes, or a run-time-typed chunk + * (process.stdioWriteDyn): the SCR_STDIO_WRITE_SLOT build of the events + * unit carries the shared write body, the dynamic-chunk entry point and + * Node's anonymous write function. Every other program links the default + * object, byte for byte. */ + stdioWriteSlot: rule({ fns: ["process.stdioSlot", "process.stdioWriteDyn"] }), stream: rule({ initial: (mod) => (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), fns: [...STREAM_LIB_FNS, "dyn.builtinStreamClass"], @@ -1314,6 +1323,7 @@ function noRuntimeFeatures(): RuntimeFeatures { searchParams: false, urlIdna: false, stdioExtended: false, + stdioWriteSlot: false, streamWriteErrors: false, moduleNamespace: false, moduleNamespaceDyn: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 0e7bccafd..faea14004 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -33,6 +33,7 @@ const features = { warningReplace: false, stdioExtended: false, streamWriteErrors: false, + stdioWriteSlot: false, nodeIsland: false, brotli: false, moduleNamespace: false, diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index 24bde2db5..3e7c8930a 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -377,6 +377,7 @@ async function build() { "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", "SCR_STREAM_WRITE_ERRORS", + "SCR_STDIO_WRITE_SLOT", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 5e3b722d2..630a261b8 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -324,6 +324,29 @@ export function variantsFor(source) { when: { stdioExtended: true, nodeIsland: true }, defines: ["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"], }); + // The typed process output write slot (the write method as a value, its + // replacement, run-time-typed chunks): the shared write body, the + // dynamic-chunk entry point and Node's anonymous write function. Every + // other program keeps the default object, byte for byte. + for (const [id, when, defines] of [ + ["stdio-write-slot", { stdioWriteSlot: true }, ["SCR_STDIO_WRITE_SLOT"]], + [ + "stdio-extended-write-slot", + { stdioExtended: true, stdioWriteSlot: true }, + ["SCR_STDIO_EXTENDED", "SCR_STDIO_WRITE_SLOT"], + ], + [ + "node-island-write-slot", + { nodeIsland: true, stdioWriteSlot: true }, + ["SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"], + ], + [ + "stdio-extended-node-island-write-slot", + { stdioExtended: true, nodeIsland: true, stdioWriteSlot: true }, + ["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"], + ], + ]) + variants.push({ id, when, defines }); } // Programs with Node islands link the event loop with the Node island slot; // every other program keeps the default object, byte for byte. diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index e8af727ea..9d58f77fd 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -546,6 +546,7 @@ async function build() { "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", "SCR_STREAM_WRITE_ERRORS", + "SCR_STDIO_WRITE_SLOT", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts index 9157d7bd0..a0107b78b 100644 --- a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts +++ b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts @@ -24,6 +24,38 @@ function selected( return matches[0]; } +test("the typed stdio write slot selects the events build that carries it", () => { + const plain = { stdioExtended: false, nodeIsland: false, workers: false }; + // Alone, the write slot selects its own build. + expect(selected("scr_events.c", { ...plain, stdioWriteSlot: true }).defines).toEqual([ + "SCR_STDIO_WRITE_SLOT", + ]); + // Beside the other events features it keeps each one's defines. + expect( + selected("scr_events.c", { ...plain, stdioExtended: true, stdioWriteSlot: true }).defines, + ).toEqual(["SCR_STDIO_EXTENDED", "SCR_STDIO_WRITE_SLOT"]); + expect( + selected("scr_events.c", { ...plain, nodeIsland: true, stdioWriteSlot: true }).defines, + ).toEqual(["SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"]); + expect( + selected("scr_events.c", { + ...plain, + stdioExtended: true, + nodeIsland: true, + stdioWriteSlot: true, + }).defines, + ).toEqual(["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"]); + // A program that never reaches the write slot keeps the old object. + expect( + selected("scr_events.c", { + ...plain, + stdioExtended: true, + nodeIsland: true, + stdioWriteSlot: false, + }).defines, + ).toEqual(["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"]); +}); + test("web streams and Node islands select an async build that defines both", () => { const variant = selected("scr_async.c", { webStreams: true, nodeIsland: true, workers: false }); expect(variant.defines).toEqual( diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 8794c270c..e73515f66 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1312,9 +1312,13 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r #endif +#ifdef SCR_STDIO_WRITE_SLOT /* stream.write(chunk[, encoding][, callback]) on a process output stream: * Node's Writable.write validation, then a synchronous descriptor write. - * Returns +1 (true/false) or NULL with an exception pending. */ + * Returns +1 (true/false) or NULL with an exception pending. The typed + * write-slot build factors it out of scr_stdio_method so the dynamic-chunk + * entry point below shares it; every other build keeps the method's own + * body, byte for byte. */ static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_t argc) { ScrDyn *arg = argc ? args[0] : scr_dyn_undefined(); ScrDyn *result = NULL; @@ -1356,6 +1360,7 @@ static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_ if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); return scr_dyn_new_bool(true); } +#endif static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); @@ -1374,7 +1379,42 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_dyn_arr_push(result, columns); scr_dyn_arr_push(result, rows); } else if (method == STDIO_WRITE && stream->fd != 0) { +#ifdef SCR_STDIO_WRITE_SLOT result = scr_stdio_write_call(stream, args, argc); +#else + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); + if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } + if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + goto done; + } + ScrBytes *bytes = NULL; + if (arg->kind == SCR_DYN_STR) { + if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { + result = scr_stdio_refusal("write encoding"); + goto done; + } + ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); + bytes = scr_bytes_from_str(arg->v.str, enc); + scr_str_release(enc); + } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { + bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); + } else { + scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); + goto done; + } + if (!bytes) goto done; + int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); + scr_bytes_release(bytes); + if (error) { + scr_stdio_queue_write_error(stream, callback, error); + result = scr_dyn_new_bool(false); + goto done; + } + if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); + result = scr_dyn_new_bool(true); +#endif } else if (method == STDIO_ON || method == STDIO_ONCE || method == STDIO_OFF) { ScrDyn *listener = argc > 1 ? args[1] : scr_dyn_undefined(); if (listener->kind != SCR_DYN_FUNC) { @@ -1595,8 +1635,14 @@ ScrDyn *scr_process_stdio(double fd) { ScrClosure *cb = scr_closure_new(NULL, 1); cb->caps[0] = scr_box_new(SCR_BOX_F64); scr_box_set_f64(cb->caps[0], i); +#ifdef SCR_STDIO_WRITE_SLOT + // Node's process.stdout.write is an anonymous (bound) function: + // .name is "" there, while the other stream methods carry their names. scr_stdio_methods[i] = scr_dyn_new_func( cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", i == STDIO_WRITE ? "" : scr_stdio_names[i]); +#else + scr_stdio_methods[i] = scr_dyn_new_func(cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", scr_stdio_names[i]); +#endif } scr_stdio_write_hook = scr_stdio_intercept; } @@ -1612,6 +1658,7 @@ double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path) { return stream ? stream->fd : 0; } +#ifdef SCR_STDIO_WRITE_SLOT /* process.stdout/stderr.write(chunk) with a run-time chunk: the stream * method's own validation and write, without creating the stream value * or installing the write hook. False with an exception pending when the @@ -1624,6 +1671,7 @@ bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk) { scr_dyn_release(result); return accepted; } +#endif /* ── the process 'exit' event ───────────────────────────────────────── * Listeners run SYNCHRONOUSLY when the process exits: at the end of a From f6f1844b1a4e4a5203e0f7590ff17a540ce9997f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:50:25 -0700 Subject: [PATCH 090/193] Update the write-callback refusal snapshot Error-taking write completion callbacks lower now (W3), so the diagnostics corpus no longer reports SC2020 for them. The end-callback refusal it pins is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../stream-completion-callback-error.ts.txt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt b/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt index f3b876cc7..0e948a3f1 100644 --- a/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt +++ b/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt @@ -1,12 +1,3 @@ -stream-completion-callback-error.ts:10:17 - error SC2020: 'write completion callbacks taking the error argument' is part of the standard library types but has no scriptc lowering yet - - 9 | }); - 10 | sink.write("a", (err) => console.log("written", err)); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 11 | sink.end((err?: Error | null) => console.log("ended", err)); - - hint: () => void callbacks are supported (listen for 'error' to observe failures) - stream-completion-callback-error.ts:11:10 - error SC2020: 'end callbacks with parameters' is part of the standard library types but has no scriptc lowering yet 10 | sink.write("a", (err) => console.log("written", err)); From 223973a56b9d65f29428ee2579f0a51c344df432 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:18:19 -0700 Subject: [PATCH 091/193] Store a discarding mapper's answer as an undefined array element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Array.from({ length: n }, () => undefined)` and `.map(() => undefined)` type as `undefined[]`, which the type mapping represents as an array of unit-union elements. Callback-driven producers take the element from the lowered callback's return type, and a standalone `undefined` in return position maps to the void convention — its answer is erased before it could be stored — so every discarding callback was refused (SC2009; pi's tui terminal-color palette). A producer whose result element is the unit-only union now gives the callback that union as its contextual return, exactly as the array element mapping substitutes it for a `void` element: the arrow's `undefined` answer becomes the present unit value. `[undefined, ...]`, sparse literals and an explicit `undefined[]` annotation already had that representation. Corpus 8527 pins the palette field, the mapper's call order and index, the `.map` and `Array.from(array, ...)` forms, `in`/JSON and the empty case under Node and natively. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-callbacks.ts | 19 +++++++- .../lowering/containers/array-construction.ts | 24 ++++++++-- .../lowering/containers/callback-arguments.ts | 36 +++++++++++++++ ...array-callbacks-undefined-elements.ts.json | 6 +++ ...8527-array-callbacks-undefined-elements.ts | 44 +++++++++++++++++++ ...array-callbacks-undefined-elements.ts.json | 1 + 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json create mode 100644 tests/corpus/8527-array-callbacks-undefined-elements.ts create mode 100644 tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index ed8c6ddce..71d3d0876 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -56,6 +56,7 @@ import { typeKey } from "../../type-mapper.js"; import { lowerArrayCallback, requireProducedArrayElement, + unitOnlyArrayElement, callbackArrayElement, truthyFilterCallback, } from "./callback-arguments.js"; @@ -108,7 +109,14 @@ export function lowerArrayHofCall( } const callback = booleanFilter ? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false } - : lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem)); + : lowerArrayCallback( + lowerer, + argNode, + [valueT], + arrayOf(elem), + false, + method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined, + ); const { fnArg, arity } = callback; // Only an array that can hold elements outside its element type needs to // leave an element its callback ignores unread. @@ -659,7 +667,14 @@ export function lowerArrayUnionHofCall( ? valueArms[0]! : { kind: "union", unionId: lowerer.unions.intern(valueArms) }; const argNode = call.arguments.nodes[0]!; - const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT); + const { fnArg, arity } = lowerArrayCallback( + lowerer, + argNode, + [valueT], + receiverT, + false, + method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined, + ); const fnRet = fnArg.type.ret; if (method === "map" && fnRet.kind === "void") { // `void[]` has no element (see the plain-receiver map path). diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 2a3ad64c0..e286a02b8 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -56,6 +56,8 @@ import { import { requireProducedArrayElement, lowerArrayCallback, + lowerArrayCallbackAnswering, + unitOnlyArrayElement, callbackArrayElement, } from "./callback-arguments.js"; import { classGeneratorIterable } from "../class-iterables.js"; @@ -1002,6 +1004,8 @@ export function lowerArrayFromCall( args[1]!, [input.element], arrayOf(input.element), + false, + unitOnlyArrayElement(lowerer, call) ?? undefined, ); if (callback.arity > 2) lowerer.noLowering("Array.from mapper with an array parameter", args[1]!); @@ -1123,7 +1127,14 @@ export function lowerArrayFromCall( if (paramSymbol) lowerer.runtimeOptionalBindingTypes.set(paramSymbol, valueT); let callback: ReturnType; try { - callback = lowerArrayCallback(lowerer, mapper, [valueT], arrT); + callback = lowerArrayCallback( + lowerer, + mapper, + [valueT], + arrT, + false, + unitOnlyArrayElement(lowerer, call) ?? undefined, + ); } finally { if (paramSymbol) { if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(paramSymbol); @@ -1162,7 +1173,14 @@ export function lowerArrayFromCall( // mapper that writes the source changes later reads, as in Node), // so this is a loop over the typed array, not a toArray snapshot. if (src.type.kind === "bytes" && args.length === 2) { - const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64)); + const callback = lowerArrayCallback( + lowerer, + args[1]!, + [F64], + arrayOf(F64), + false, + unitOnlyArrayElement(lowerer, call) ?? undefined, + ); if (callback.arity > 2) lowerer.noLowering("Array.from mapper with an array parameter", args[1]!); const fnRet = callback.fnArg.type.ret; @@ -1245,7 +1263,7 @@ export function lowerArrayFromCall( ); } if (n.type.kind !== "f64") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!)); - const fnArg = lowerer.lowerExpr(args[1]!); + const fnArg = lowerArrayCallbackAnswering(lowerer, args[1]!, unitOnlyArrayElement(lowerer, call)); // The mapper may declare any prefix of (v, i): v is the checker's own // `unknown` (Node passes undefined there — the dyn undefined singleton // here), i the index. The result type must be a legal array element. diff --git a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts index 3b504861a..80b34bd86 100644 --- a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts +++ b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts @@ -306,6 +306,42 @@ function readsParameter(argNode: ts.Expression, index: number): boolean { ); } +/** The result array's element when it is the unit-only union (`undefined[]`, + * the sparse-literal/`Array.from(..., () => undefined)` shape): a callback + * that answers `undefined` stores a PRESENT unit element, so the producer + * gives the callback that union as its contextual return. The standalone + * `undefined` in return position otherwise maps to the void convention, + * whose answer is erased before it could be stored. Null for other elements. */ +export function unitOnlyArrayElement(lowerer: Lowerer, call: ts.CallExpression): IrType | null { + const result = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (result?.kind !== "array") return null; + const elem = result.elem; + if (isUnitType(elem)) return elem; + return elem.kind === "union" && + lowerer.unions.get(elem.unionId)?.arms.every((arm) => isUnitType(arm)) === true + ? elem + : null; +} + +/** Lower a callback whose answer becomes an array element, with the result + * element as its contextual return when that element is the unit-only union + * (see unitOnlyArrayElement). Other producers lower the callback directly. */ +export function lowerArrayCallbackAnswering( + lowerer: Lowerer, + fnNode: ts.Expression, + expected: IrType | null, +): IrExpr { + if (expected === null) return lowerer.lowerExpr(fnNode); + const previous = lowerer.contextualFunctionReturns.get(fnNode); + lowerer.contextualFunctionReturns.set(fnNode, expected); + try { + return lowerer.lowerExpr(fnNode); + } finally { + if (previous === undefined) lowerer.contextualFunctionReturns.delete(fnNode); + else lowerer.contextualFunctionReturns.set(fnNode, previous); + } +} + /** Preserve the checker-selected payload ABI when a callback's runtime * return gained only undefined. The array state byte carries that value. */ export function callbackArrayElement( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json new file mode 100644 index 000000000..977cf0e45 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8527-array-callbacks-undefined-elements.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8527-array-callbacks-undefined-elements.ts b/tests/corpus/8527-array-callbacks-undefined-elements.ts new file mode 100644 index 000000000..f8b4a80d3 --- /dev/null +++ b/tests/corpus/8527-array-callbacks-undefined-elements.ts @@ -0,0 +1,44 @@ +// Array producers whose callback answers `undefined`: the result is an +// `undefined[]`, whose elements are PRESENT undefined values (JSON prints +// them as null, `in` sees them), and the mapper still runs once per index. + +type PendingQuery = { + palette: undefined[]; + timer: number | undefined; +}; + +function build(n: number): PendingQuery { + return { + palette: Array.from({ length: n }, () => undefined), + timer: undefined, + }; +} + +const query = build(3); +console.log(query.palette.length); +console.log(query.palette[0] === undefined, query.palette[2] === undefined); +console.log(0 in query.palette, 2 in query.palette, 3 in query.palette); +console.log(query.timer === undefined); + +// The mapper runs once per index, in order. +const seen: number[] = []; +const mapped: undefined[] = Array.from({ length: 4 }, (_value, index) => { + seen.push(index); + return undefined; +}); +console.log(mapped.length, seen.join(",")); +console.log(mapped.every((value) => value === undefined)); + +// Array.from over an existing array, and `.map` over an existing array, +// both with a discarding mapper. +const fromArray: undefined[] = Array.from([1, 2, 3], (_n) => undefined); +console.log(fromArray.length, fromArray[0] === undefined); +const discarded: undefined[] = ["a", "b"].map((_text) => undefined); +console.log(discarded.length, discarded[1] === undefined); + +// A dense literal of undefined elements keeps the same shape. +const literal: undefined[] = [undefined, undefined]; +console.log(literal.length, literal[1] === undefined); + +console.log(JSON.stringify(query.palette)); +console.log(JSON.stringify(mapped)); diff --git a/tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json b/tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json new file mode 100644 index 000000000..8c0e66c3c --- /dev/null +++ b/tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3600 } From 49b133751c5bce606eef37ec95d02e4349a8eb5c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:24:48 -0700 Subject: [PATCH 092/193] Adopt a contextual union's map arm for a bare new Map() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Map()` has no type arguments, and the no-argument constructor gives the checker nothing to infer them from: the expression types as Map however the destination spells it. Construction adopted a contextual type only when it mapped to a map, so a nullable or readonly annotation — `Map | undefined`, `Map | null`, `ReadonlyMap | undefined`, an optional record field — refused the very value its slot was built for (SC2003; pi's extension registry, whose `handlers?: Map<...>` fields start empty). A Map value can only inhabit a union's map arm, so construction now adopts that arm when the contextual union has exactly one: the remainder of the annotation is the nullish absence the value never is. Two different map arms stay refused (SC2003), because the layout would be ambiguous. Corpus 8528 pins the nullable, readonly and optional-field annotations, a parameter slot, iteration after the adoption, and remains byte-exact against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 21 ++++++- ...28-new-collection-contextual-union.ts.json | 6 ++ .../8528-new-collection-contextual-union.ts | 59 +++++++++++++++++++ ...28-new-collection-contextual-union.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json create mode 100644 tests/corpus/8528-new-collection-contextual-union.ts create mode 100644 tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 014bbc71a..487f221c8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -10071,6 +10071,21 @@ function lowerProgramClassNew( }); } +/** The supported map layout a contextual type pins for `new Map()`: the type + * itself, or the single map arm of a union — a nullable annotation such as + * `Map | undefined`, or `ReadonlyMap | undefined`, where a Map + * value can only inhabit the map arm and the no-argument constructor gives + * the checker nothing to infer the type arguments from. */ +function contextualMapLayout(lowerer: Lowerer, ctx: ts.Type): IrType | null { + const mapped = lowerer.mapTypeOf(ctx); + if (mapped?.kind === "map") return mapped; + if (mapped?.kind !== "union") return null; + const arms = (lowerer.unions.get(mapped.unionId)?.arms ?? []).filter( + (arm): arm is IrType & { kind: "map" } => arm.kind === "map", + ); + return arms.length === 1 ? arms[0]! : null; +} + function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { const parent = expr.parent; if ( @@ -11064,10 +11079,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = lowerer.mapTypeOf(inferred); } else if (mapped?.kind !== "map" || contextualSeed) { const ctx = lowerer.checker.getContextualType(expr); - const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null; - if (ctx && ctxMapped?.kind === "map") { + const ctxMap = ctx ? contextualMapLayout(lowerer, ctx) : null; + if (ctx && ctxMap !== null) { tsType = ctx; - mapped = ctxMapped; + mapped = ctxMap; } } if (seedArg && !entriesLit && mapped?.kind === "map") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json new file mode 100644 index 000000000..abdf9b9f4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8528-new-collection-contextual-union.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8528-new-collection-contextual-union.ts b/tests/corpus/8528-new-collection-contextual-union.ts new file mode 100644 index 000000000..b5aecc244 --- /dev/null +++ b/tests/corpus/8528-new-collection-contextual-union.ts @@ -0,0 +1,59 @@ +// `new Map()` (no type arguments) in a contextual slot whose type is a +// union: `Map | undefined`, `ReadonlyMap | null`. A Map value +// can only inhabit the map arm, so the construction adopts that layout +// instead of the checker's bare Map. + +function lookup(handlers: Map | undefined, key: string): string { + if (handlers === undefined) return "absent"; + const found = handlers.get(key); + return found === undefined ? "missing" : String(found); +} + +const counts: Map | undefined = new Map(); +if (counts !== undefined) { + counts.set("a", 1); + counts.set("b", counts.size + 2); + console.log(counts.size, counts.get("a"), counts.get("b")); +} +console.log(lookup(counts, "a"), lookup(counts, "z"), lookup(undefined, "a")); +console.log(counts instanceof Map); + +// A nullable annotation takes the same arm. +const nullable: Map | null = new Map(); +console.log(nullable !== null, nullable instanceof Map); + +// A readonly view announces no writes; the value is still a real Map. +const readonlyView: ReadonlyMap | undefined = new Map(); +if (readonlyView !== undefined) { + console.log(readonlyView.size, readonlyView.get("missing") === undefined); +} + +// The contextual type of an optional record field is the same union. +type Registry = { + handlers?: Map; + names?: Set; +}; + +function createRegistry(): Registry { + return { handlers: new Map(), names: new Set() }; +} + +const registry = createRegistry(); +if (registry.handlers !== undefined) { + registry.handlers.set("on", 7); + console.log(registry.handlers.size, registry.handlers.get("on")); +} +if (registry.names !== undefined) { + registry.names.add("first"); + console.log(registry.names.size, registry.names.has("first")); +} + +// A parameter slot infers the same way for Map and Set. +function describe(map: Map | undefined, set: Set | undefined): string { + return `${map === undefined ? "-" : map.size}/${set === undefined ? "-" : set.size}`; +} +console.log(describe(new Map(), new Set()), describe(undefined, undefined)); + +// Iteration and JSON-free surfaces keep the element types. +const entries = new Map([["x", 1], ["y", 2]] as [string, number][]); +console.log([...entries.keys()].join(","), [...entries.values()].join(",")); diff --git a/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json b/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json new file mode 100644 index 000000000..6c25b05fd --- /dev/null +++ b/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 3700 } From 5afe93093fa349caabae9a4b94e0f25ee72c4402 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:36:03 -0700 Subject: [PATCH 093/193] Accept a wider lambda whose own signature is already its completed ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lambda declaring an optional or defaulted trailing parameter — the `(a, b, loose?) => …` semver comparator, the sort callback whose surplus position is a flag — was refused at its birth fence (SC1090) even though every read of it spells that position as `T | undefined`, the completed native ABI. The fence compared the slot's mapped type against the contextual lambda's, which misses two shapes the checker accepts: a lambda whose own `any` parameter mapType leaves unmapped, and a *contextually* typed lambda whose OWN signature is wider than the slot it is written under. requireExactArityValue now admits a lambda whose own call signature maps to the slot position for position, and cleanFuncAdaptable accepts a source with extra trailing parameters when each of them completes with the undefined value, the same absence a call through the slot passes. `sort`/`toSorted` separately required a comparator of at most two parameters; it now adapts the first two and lets the surplus ones complete as undefined, so the imported three-parameter comparator sorts. Corpus 8529 pins the surplus-parameter comparators (optional, defaulted, `any`-typed), the direct calls through them, `toSorted`'s untouched receiver, and remains byte-exact against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 49 +++++++++++++++++++ .../lowering/coercions/function-adapters.ts | 20 ++++++-- .../lowering/containers/array-callbacks.ts | 13 +++-- ...529-sort-comparator-surplus-params.ts.json | 6 +++ .../8529-sort-comparator-surplus-params.ts | 42 ++++++++++++++++ ...529-sort-comparator-surplus-params.ts.json | 1 + 6 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json create mode 100644 tests/corpus/8529-sort-comparator-surplus-params.ts create mode 100644 tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 45329d077..c37eba707 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -630,6 +630,41 @@ export function checkDefaultParamBodyType( } } +/** Whether a lambda's OWN signature maps to its completed ABI position for + * position — the contract its reads carry (mapType spells `x?: T` as the + * `T | undefined` slot). Two positions can escape the mapped-TYPE + * comparison in requireExactArityValue: a parameter the checker spells + * `any` holds the checked-dynamic value that mapType leaves unmapped when + * its spelling is context-free, and a *contextually* typed lambda's own + * signature need not be the slot's (a comparator with a surplus optional + * parameter under a two-parameter slot). */ +function lambdaSpellsCompletedAbi( + lowerer: Lowerer, + lambda: ts.ArrowFunction | ts.FunctionExpression, + funcType: IrType, +): boolean { + if (funcType.kind !== "func" || funcType.rest === true) return false; + const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda)); + if (sigs.length !== 1) return false; + const sig = sigs[0]!; + const params = sig.getParameters(); + if (params.length !== funcType.params.length) return false; + for (let i = 0; i < params.length; i++) { + const pTs = lowerer.checker.getTypeOfSymbol(params[i]!); + const slot = funcType.params[i]!; + if ((pTs.flags & ts.TypeFlags.Any) !== 0) { + if (slot.kind !== "dyn") return false; + continue; + } + const mappedParam = lowerer.mapTypeOf(pTs); + if (!mappedParam || !typeEquals(mappedParam, slot)) return false; + } + const retTs = lowerer.checker.getReturnTypeOfSignature(sig); + if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn"; + const mappedRet = lowerer.mapTypeOf(retTs); + return mappedRet !== null && typeEquals(mappedRet, funcType.ret); +} + /** DECISION (docs/ir.md): function VALUES carry one completed native ABI. * Optional/default parameters appear as literal `T | undefined` slots; * typed rest parameters appear as one trailing typed-array slot plus the @@ -675,6 +710,20 @@ export function requireExactArityValue( ? lowerer.mapTypeOf(lowerer.typeOf(contextual)) : null; if (mapped && typeEquals(mapped, funcType)) return; + // A lambda whose OWN signature already spells the completed ABI is a + // value: every read of it types it that way, and its slot converts it + // (the surplus-parameter and undefined-arm adapters). The mapped-type + // comparison above misses two such lambdas — one whose `any` parameter + // mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number` + // beside a two-parameter slot), and one whose signature is wider than + // the contextual slot it is written under. + if ( + contextual !== null && + (ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) && + lambdaSpellsCompletedAbi(lowerer, contextual, funcType) + ) { + return; + } // An 'any' slot (`C.prototype.m = function (a, b) {…}` through an // untyped receiver), or a slot whose type maps to a checked-dynamic // value (a member of a literal lowered with a generic function's type diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 44bca35e1..50366eeb6 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -114,10 +114,22 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool return false; } +/** Whether an omitted parameter position completes with the native + * undefined value: a checked-dynamic slot takes undefined as-is, and an + * undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI + * spelling. */ +export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true; + return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0; +} + /** Whether all parameters and the result adapt without a trap-only path. * Rest signatures are excluded, except a typed-rest source in a slot with * fixed parameters (the rest packing adapter); extra destination - * parameters are ignored. */ + * parameters are ignored. A source with MORE parameters than the slot is + * clean when every extra trailing one is omittable — the adapter passes + * that absent value explicitly (funcCoerceAdapter's omittedParams), the + * same way a call through the slot would. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, @@ -125,8 +137,10 @@ export function cleanFuncAdaptable( ): boolean { if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; - if (src.params.length > dst.params.length) return false; - for (let i = 0; i < src.params.length; i++) { + for (let i = dst.params.length; i < src.params.length; i++) { + if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; } if (dst.ret.kind === "void") return true; diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 71d3d0876..1224bce3e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -70,6 +70,7 @@ import { import { dynamicReceiverThrows } from "./dynamic-receivers.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { checkedFilterNarrowing } from "./filter-predicates.js"; +import { omittableTrailingParam } from "../coercions/function-adapters.js"; /** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct * call of a synthetic module function — one per method + element/result @@ -2657,11 +2658,15 @@ export function lowerArraySortCall( let fnArg = undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT); // Reusable JS comparators often accept checked values. Adapt the array's - // element ABI just as other array callbacks do before sorting. + // element ABI just as other array callbacks do before sorting. A + // comparator declaring MORE parameters than sort passes (`(a, b, loose?)`, + // the imported semver shape) receives the surplus ones as undefined — the + // completion an ordinary two-argument call performs — so it adapts to the + // (a, b) slot like any other wider signature. if ( fnArg.type.kind === "func" && - fnArg.type.params.length <= 2 && - fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param)) + fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) && + fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param)) ) { // Arithmetic on inferred JS values retains a checked result because // it can produce BigInt. The sort callback still requires a number. @@ -2670,7 +2675,7 @@ export function lowerArraySortCall( ? F64 : fnArg.type.ret; const expected = funcOf( - fnArg.type.params.map(() => elem), + fnArg.type.params.slice(0, 2).map(() => elem), result, ); if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json new file mode 100644 index 000000000..56abd06eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8529-sort-comparator-surplus-params.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8529-sort-comparator-surplus-params.ts b/tests/corpus/8529-sort-comparator-surplus-params.ts new file mode 100644 index 000000000..9b0d0054b --- /dev/null +++ b/tests/corpus/8529-sort-comparator-surplus-params.ts @@ -0,0 +1,42 @@ +// A comparator may declare more parameters than `sort` passes: the surplus +// positions receive the native undefined value, exactly as in a two-argument +// call. The imported `(a, b, loose) => …` comparators and lambdas with an +// optional or defaulted trailing parameter take this shape, and a lambda +// whose own signature already spells its completed ABI is a value wherever +// its slot converts. + +type Comparer = (a: string, b: string, loose?: boolean) => number; + +const seen: string[] = []; +const cmp: Comparer = (a, b, loose) => { + seen.push(loose === undefined ? "absent" : String(loose)); + return a < b ? -1 : a > b ? 1 : 0; +}; + +const versions = ["1.9.0", "1.10.0", "1.2.0"]; +console.log([...versions].sort(cmp).join(",")); +console.log(seen.length > 0 && seen.every((entry) => entry === "absent")); +console.log(versions.join(",")); + +// `toSorted` adapts the same way and leaves the receiver untouched. +console.log(versions.toSorted(cmp).join(","), versions.join(",")); + +// Direct calls through the value complete the same parameters. +console.log(cmp("a", "b"), cmp("b", "a"), cmp("a", "a", true)); + +// An inline comparator with an optional surplus parameter sorts by number. +const nums = [3, 1, 2]; +const descending = (a: number, b: number, dir?: boolean): number => + dir === undefined ? b - a : a - b; +console.log(nums.toSorted(descending).join(",")); +console.log([...nums].sort((a, b, dir?: boolean) => (dir ? a - b : b - a)).join(",")); +console.log(nums.join(",")); + +// A defaulted surplus parameter keeps its default when sort omits it. +const withDefault = (a: string, b: string, scale = 1): number => + (a < b ? -1 : a > b ? 1 : 0) * scale; +console.log(["b", "a", "c"].sort(withDefault).join("")); +console.log(withDefault("a", "b"), withDefault("a", "b", 3)); + +// A one-parameter comparator is still a prefix of the slot. +console.log(["d", "c", "a"].sort((left) => left.charCodeAt(0) - 97).join(",")); diff --git a/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json b/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json new file mode 100644 index 000000000..6c25b05fd --- /dev/null +++ b/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 3700 } From 3328ed2aa7ff8038d71ce0dd88525ed84023f159 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:03:59 -0700 Subject: [PATCH 094/193] Lower union member reads the checker narrowed away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a member off a union-typed value is what most pi refusals at `SC1090` came down to: `credential.type` on `ApiKeyCredential | OAuthCredential`, `session.role`, `entry.timestamp`, `tool.operations`, `arms.length`, `renderer.hasOverlayEntries`. Three read shapes reach the same dispatch and now answer: - A checker-NARROWED receiver keeps the wider union it stores, because lowering erases flow narrowing (`stored?.type` after the earlier arm returned, `(entry as { role: string }).role`). The dispatcher now reads the member off whichever arm the value holds — an arm that does not declare it answers undefined, exactly Node's get — and narrows the answer to the checker's type, which traps on an arm the narrowing ruled out (types do not lie). This fallback sits after every existing lowering, so only refusals gain one. - A member the arms answer through an ACCESSOR (a getter inherited from a shared base class, `renderer.hasOverlayEntries`) has no field slot `unionDisc` could read, so the read dispatches on the tag and calls each arm's own getter. Data-field unions keep their `unionDisc` lowering. - `.length` on a union of arrays and strings (`Model[] | ModelRef[]`, `string[] | Buffer[]`) dispatches on the tag and answers each arm's own length. The member read moves out of `lowerUnionProperty` into `lowerUnionValueRead`, which both entry points share, and `missingFieldAsUndefined` threads the narrowed-receiver get semantics through the keyed read. Corpus 8520/8521/8522 pin the three shapes differentially. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 278 +++++++++++++++++- ...8520-union-narrowed-receiver-reads.ts.json | 6 + .../8521-union-accessor-member-read.ts.json | 6 + .../corpus/8522-union-array-length.ts.json | 6 + .../8520-union-narrowed-receiver-reads.ts | 84 ++++++ .../corpus/8521-union-accessor-member-read.ts | 58 ++++ tests/corpus/8522-union-array-length.ts | 37 +++ ...8520-union-narrowed-receiver-reads.ts.json | 1 + .../8521-union-accessor-member-read.ts.json | 1 + .../8522-union-array-length.ts.json | 1 + 10 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json create mode 100644 tests/corpus/8520-union-narrowed-receiver-reads.ts create mode 100644 tests/corpus/8521-union-accessor-member-read.ts create mode 100644 tests/corpus/8522-union-array-length.ts create mode 100644 tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json create mode 100644 tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json create mode 100644 tests/harness/corpus-costs/8522-union-array-length.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf1013..e2595f465 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1617,6 +1617,19 @@ export function lowerPropertyAccessExpr( } const asserted = assertedMemberRead(lowerer, expr.expression, expr.name.text, recvLowered, loc); if (asserted) return asserted; + // Lowering erases flow narrowing, so a checker-NARROWED receiver keeps + // the wider union it stores (`stored?.type` after the earlier arm + // returned, `(entry as { role: string }).role`): the checker's type + // names one record while the value still carries its siblings. Read the + // member off whichever arm the value holds — an arm that does not + // declare it answers undefined, exactly Node's get — and narrow the + // answer to the checker's type, which traps on an arm the checker's + // narrowing ruled out (types do not lie). Reaching this point means no + // lowering above claimed the read, so only refusals gain a lowering. + if (recvLowered.type.kind === "union") { + const read = lowerUnionValueRead(lowerer, expr, recvLowered, true); + if (read) return lowerer.maybeNarrow(read, expr); + } // A checker-`any` receiver whose value is a FRESH typed result of an // earlier step in the chain (`out.split(" ").length`, where split // extracted the string and answered a native string[]): the member read @@ -3642,8 +3655,33 @@ export function lowerUnionProperty( "shared property access without a represented union receiver", ); } - const def = lowerer.unions.get(value.type.unionId); - if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`); + const read = lowerUnionValueRead(lowerer, expr, value); + if (read) return read; + lowerer.unsupported( + "SC1090", + expr, + `reading '${expr.name.text}' on a union-typed value (every arm must be an object/record ` + + `with a same-typed field '${expr.name.text}'; ` + + `${NARROW_FIRST})`, + ); +} + +/** The member read itself on a lowered UNION value: `lowerUnionProperty` + * after it has produced the value (retagged to the checker's union when + * the checker removed arms) and the dispatcher's narrowed-receiver + * fallback, which reaches the same read with the value a checker-narrowed + * receiver still stores. Returns null when no arm can answer — the caller + * owns the diagnostic. */ +export function lowerUnionValueRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + value: IrExpr, + missingFieldAsUndefined = false, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const unionId = value.type.unionId; + const def = lowerer.unions.get(unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`); const field = expr.name.text; if (field === "length") { const presentArms = def.arms.filter((arm) => !isUnitType(arm)); @@ -3675,6 +3713,42 @@ export function lowerUnionProperty( }; } } + // A union of arrays and strings (`Model[] | ModelRef[]`, + // `string[] | Buffer[]`): every arm answers its own length, so the + // read dispatches on the tag. Node's `.length` is a number for both, + // and no arm can be absent here (a unit arm would have to throw on + // this read, which the checker's narrowing already excludes). + if ( + def.arms.length > 1 && + def.arms.every((arm) => arm.kind === "array" || arm.kind === "string") + ) { + return perArmUnionRead( + lowerer, + expr, + unionId, + value, + F64, + "%unionLength", + (arm, _tag, receiver) => + arm.kind === "string" + ? { + kind: "strIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc: locOf(expr), + } + : { + kind: "arrIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc: locOf(expr), + }, + ); + } } const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm)); if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") { @@ -3688,11 +3762,7 @@ export function lowerUnionProperty( field, ); if (receiver === null) { - const helper = lowerer.narrowedArmHelper( - value.type.unionId, - objectType, - locOf(expr.expression), - ); + const helper = lowerer.narrowedArmHelper(unionId, objectType, locOf(expr.expression)); if (helper !== null) receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression)); } @@ -3708,6 +3778,14 @@ export function lowerUnionProperty( } } } + // A member the arms answer through an ACCESSOR (a getter inherited + // from a shared base class — `renderer.hasOverlayEntries` on + // `FullscreenRenderer | RegularRenderer`): there is no field slot + // `unionDisc` could read, so dispatch on the tag and call each arm's + // own getter. Fires only when some arm's answer is an accessor, so + // data-field unions keep their `unionDisc` lowering. + const accessorRead = lowerUnionAccessorRead(lowerer, expr, unionId, value); + if (accessorRead) return accessorRead; let common: IrType | null = null; for (const arm of def.arms) { let ft: IrType | undefined; @@ -3736,7 +3814,15 @@ export function lowerUnionProperty( // arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv | // Record`, the tail read of `loaded?.config.script`. const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) }; - const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field); + const keyed = lowerUnionKeyedRead( + lowerer, + expr, + unionId, + value, + key, + field, + missingFieldAsUndefined, + ); if (keyed) return keyed; // Class arms declaring the field with compatible but different types // (`T | undefined` beside `T`): per-arm slot reads widened to one type. @@ -3763,12 +3849,178 @@ export function lowerUnionProperty( const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr)); if (member) return member; } - lowerer.unsupported( - "SC1090", + return null; +} + +/** Evaluate `value` once into a hidden local and answer the read by + * dispatching on the union's tag: arm `tag` (narrowed from the local) + * answers `answer(arm, tag, narrowed)`. The first non-unit arm's answer + * is the fallback branch, which a representable union never reaches. */ +function perArmUnionRead( + lowerer: Lowerer, + expr: ts.Node, + unionId: string, + value: IrExpr, + type: IrType, + name: string, + answer: (arm: IrType, tag: number, receiver: IrExpr) => IrExpr, +): IrExpr { + const loc = locOf(expr); + const def = lowerer.unions.get(unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`); + const stable = lowerer.declareHiddenLocal(name, value.type); + const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: value.type, loc }); + let result: IrExpr | null = null; + def.arms.forEach((arm, tag) => { + if (isUnitType(arm)) return; + const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value: ref(), type: arm, loc }; + const read = answer(arm, tag, receiver); + result = + result === null + ? read + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag, + value: ref(), + negated: false, + type: BOOL, + loc, + }, + then: read, + else_: result, + type, + loc, + }; + }); + if (result === null) throw new InternalCompilerError(`lowerer bug: no arm answers ${name}`); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: value, loc }], + result, + type, + loc, + }; +} + +/** A member read the arms answer through an ACCESSOR — an inherited + * getter (`this.renderer.hasOverlayEntries`). `unionDisc` reads record + * and class FIELD slots, so a getter arm needs the tag dispatch: each + * arm calls its own getter (once per read, as Node's read does). Data + * arms in the same union read their slot. Every arm must answer one + * type; a unit arm or an arm without the member declines (the caller's + * keyed read and fences own those). Null when no arm answers through an + * accessor — plain field unions keep `unionDisc`. */ +function lowerUnionAccessorRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + unionId: string, + value: IrExpr, +): IrExpr | null { + const def = lowerer.unions.get(unionId); + if (!def) return null; + const field = expr.name.text; + const getterName = `get:${field}`; + let type: IrType | null = null; + let anyAccessor = false; + const arms: { tag: number; read: (receiver: IrExpr) => IrExpr }[] = []; + for (let tag = 0; tag < def.arms.length; tag++) { + const arm = def.arms[tag]!; + if (isUnitType(arm)) return null; + let answer: IrType; + let read: (receiver: IrExpr) => IrExpr; + if (arm.kind === "record") { + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple) return null; + const data = shape.fields.find((f) => f.name === field); + if (data) { + answer = data.type; + read = (receiver) => ({ + kind: "recordGet", + obj: receiver, + shapeId: arm.shapeId, + field, + type: data.type, + loc: locOf(expr), + }); + } else { + const getter = shape.fields.find((f) => f.name === getterName); + if (!getter || getter.type.kind !== "func") return null; + anyAccessor = true; + answer = getter.type.ret; + read = (receiver) => + lowerer.coerceToExpected( + { + kind: "callValue", + callee: { + kind: "recordGet", + obj: receiver, + shapeId: arm.shapeId, + field: getterName, + type: getter.type, + loc: locOf(expr), + }, + args: [], + type: getter.type.kind === "func" ? getter.type.ret : DYN, + loc: locOf(expr), + }, + answer, + ); + } + } else if (arm.kind === "object") { + const info = lowerer.classes.get(arm.className) ?? null; + const slot = info?.fields.get(field); + if (slot !== undefined) { + answer = slot; + read = (receiver) => ({ + kind: "fieldGet", + obj: receiver, + className: arm.className, + field, + type: slot, + loc: locOf(expr), + }); + } else { + const getter = lowerer.findMethodOn(info, getterName); + if (!getter || getter.sig.ret.kind === "void") return null; + anyAccessor = true; + answer = getter.sig.ret; + read = (receiver) => + lowerer.fieldGetExpr( + { + container: "accessor", + obj: receiver, + className: arm.className, + field, + fieldType: getter.sig.ret, + }, + locOf(expr), + expr, + ); + } + } else { + return null; + } + if (answer.kind === "void" || isUnitType(answer)) return null; + if (type !== null && !typeEquals(type, answer)) return null; + type = answer; + arms.push({ tag, read }); + } + if (!anyAccessor || type === null || arms.length < 2) return null; + return perArmUnionRead( + lowerer, expr, - `reading '${field}' on a union-typed value (every arm must be an object/record ` + - `with a same-typed field '${field}'; ` + - `${NARROW_FIRST})`, + unionId, + value, + type, + "%unionMember", + (_arm, tag, receiver) => { + const entry = arms.find((a) => a.tag === tag); + if (!entry) throw new InternalCompilerError("lowerer bug: accessor arm missing"); + return entry.read(receiver); + }, ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json new file mode 100644 index 000000000..dbb935b8b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8520-union-narrowed-receiver-reads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json new file mode 100644 index 000000000..c5f221cdb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8521-union-accessor-member-read.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json new file mode 100644 index 000000000..bc5e6827e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8522-union-array-length.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8520-union-narrowed-receiver-reads.ts b/tests/corpus/8520-union-narrowed-receiver-reads.ts new file mode 100644 index 000000000..05abc2584 --- /dev/null +++ b/tests/corpus/8520-union-narrowed-receiver-reads.ts @@ -0,0 +1,84 @@ +// Lowering erases flow narrowing, so a checker-NARROWED receiver still +// stores its wider union. A member read dispatches on the arm the value +// actually holds: an arm that declares the member answers its slot, an arm +// that does not answers undefined (Node's get), and an arm the narrowing +// ruled out traps in the checked conversion. +interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: Record; +} +interface OAuthCredential { + type: "oauth"; + access: string; + refresh: string; + expires: number; +} +type Credential = ApiKeyCredential | OAuthCredential; + +function pick(stored: Credential | undefined): string | undefined { + if (stored?.type === "oauth") return stored.access; + const credential = stored?.type === "api_key" ? stored : undefined; + return credential?.key; +} +console.log("key", pick({ type: "api_key", key: "k1" })); +console.log("key absent", pick({ type: "api_key" })); +console.log("oauth", pick({ type: "oauth", access: "a1", refresh: "r1", expires: 1 })); +console.log("none", pick(undefined)); + +// An assertion narrows the checker's view of arms that carry different +// payloads; every arm declares the asserted member here. +interface Message { + role: string; + content: string; +} +interface ToolResult { + role: string; + output: string[]; +} +type AgentMessage = Message | ToolResult; +function roleOf(message: AgentMessage): string { + return (message as { role: string }).role; +} +console.log("role", roleOf({ role: "user", content: "hi" })); +console.log("role", roleOf({ role: "tool", output: ["x"] })); + +// One arm lacks the member the assertion names: Node's read answers +// undefined for that arm. +interface Stamped { + timestamp?: number; + text: string; +} +interface Unstamped { + text: string; +} +function stamp(entry: Stamped | Unstamped): number | undefined { + return (entry as { timestamp?: number }).timestamp; +} +console.log("stamp", stamp({ timestamp: 5, text: "a" })); +console.log("stamp", stamp({ text: "b" })); + +// A discriminant read re-narrows the union it came from. +interface BashOperations { + exec(command: string): string; +} +interface BashResult { + exitCode: number; +} +type BashEventResult = + | { operations: BashOperations; result?: undefined } + | { operations?: undefined; result: BashResult }; +function describe(result: BashEventResult | undefined): string { + if (result?.result) return `exit ${result.result.exitCode}`; + return `operations ${typeof result?.operations}`; +} +console.log("event", describe({ result: { exitCode: 3 } })); +console.log("event", describe({ operations: { exec: (command) => command } })); +console.log("event", describe(undefined)); + +// A narrowed array whose stored union still owns an absent arm. +function firstLength(models: string[] | undefined): number { + if (models) return models.length; + return -1; +} +console.log("first", firstLength(["a", "b"]), firstLength(undefined)); diff --git a/tests/corpus/8521-union-accessor-member-read.ts b/tests/corpus/8521-union-accessor-member-read.ts new file mode 100644 index 000000000..c6168c078 --- /dev/null +++ b/tests/corpus/8521-union-accessor-member-read.ts @@ -0,0 +1,58 @@ +// A member the arms answer through an ACCESSOR — an inherited getter, or a +// getter a subclass overrides — has no field slot to read, so the read +// dispatches on the union's tag and calls the arm's own getter, once per +// read, as Node does. A data-field arm beside a getter arm reads its slot. +const logs: string[] = []; +class Panel { + items: string[]; + constructor(items: string[]) { + this.items = items; + } + get hasOverlayEntries(): boolean { + logs.push("has"); + return this.items.length > 0; + } + get count(): number { + logs.push("count"); + return this.items.length; + } +} +class MainPanel extends Panel { + mode = "main"; +} +class AltPanel extends Panel { + mode = "alt"; + get count(): number { + logs.push("alt count"); + return this.items.length * 10; + } +} +class FixedPanel { + mode = "fixed"; + items: string[]; + hasOverlayEntries = true; + constructor(items: string[]) { + this.items = items; + } + get count(): number { + logs.push("fixed count"); + return this.items.length + 100; + } +} + +function pickPanel(which: "m" | "a", items: string[]): MainPanel | AltPanel { + return which === "m" ? new MainPanel(items) : new AltPanel(items); +} +for (const which of ["m", "a"] as const) { + const panel = pickPanel(which, which === "m" ? ["x"] : []); + console.log("panel", which, panel.mode, panel.hasOverlayEntries, panel.count); +} + +function pickMixed(which: "a" | "f", items: string[]): AltPanel | FixedPanel { + return which === "a" ? new AltPanel(items) : new FixedPanel(items); +} +for (const which of ["a", "f"] as const) { + const panel = pickMixed(which, ["y", "z"]); + console.log("mixed", which, panel.mode, panel.hasOverlayEntries, panel.count); +} +console.log("logs", logs.join(",")); diff --git a/tests/corpus/8522-union-array-length.ts b/tests/corpus/8522-union-array-length.ts new file mode 100644 index 000000000..5fa467488 --- /dev/null +++ b/tests/corpus/8522-union-array-length.ts @@ -0,0 +1,37 @@ +// `.length` on a union of arrays (and strings): every arm answers its own +// length, so the read dispatches on the tag instead of demanding one +// record arm that carries a `length` field. +interface Model { + id: string; +} +interface ModelRef { + ref: string; +} +function ids(models: Model[] | ModelRef[]): number { + return models.length; +} +console.log("models", ids([{ id: "a" }, { id: "b" }])); +console.log("refs", ids([{ ref: "r" }, { ref: "s" }, { ref: "t" }])); + +function scalars(values: string[] | number[]): number { + return values.length; +} +console.log("strings", scalars(["a", "b", "c"])); +console.log("numbers", scalars([1, 2])); + +function textual(part: string | string[]): number { + return part.length; +} +console.log("text", textual("hello")); +console.log("parts", textual(["a", "b"])); + +// The dispatch reads the value ONCE, even when the receiver is a call. +let calls = 0; +function make(fill: string): string[] { + calls++; + return [fill, fill]; +} +function measured(part: string | string[]): number { + return part.length; +} +console.log("call", measured(make("q")), "calls", calls); diff --git a/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json b/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json new file mode 100644 index 000000000..c10c572b5 --- /dev/null +++ b/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 2900 } diff --git a/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json b/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json new file mode 100644 index 000000000..26a06ea33 --- /dev/null +++ b/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2500 } diff --git a/tests/harness/corpus-costs/8522-union-array-length.ts.json b/tests/harness/corpus-costs/8522-union-array-length.ts.json new file mode 100644 index 000000000..bef270fe0 --- /dev/null +++ b/tests/harness/corpus-costs/8522-union-array-length.ts.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 2400 } From e198a9eec235a36fdca3b954c27c4eedd249939a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:06 -0700 Subject: [PATCH 095/193] Dispatch a `keyof` read whose member is `unknown` per declared key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `settings[key]` over a settings record with an `unknown` member (`type: unknown`) refuses even though every field reads through a checked-dynamic slot: the checker's answer collapses to `unknown` because the union of the field types absorbs into it, so there is no typed join for the keyed read to dispatch into. Recognize that shape — the read's type is `unknown`, the shape has no index signature, and every declared field's value is checked-dynamic representable — and dispatch per declared key answering a checked- dynamic read. The key type is the record's own `keyof`, so the answer is always present. Corpus 8523 pins the read differentially. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/element-access.ts | 14 +++++++ .../8523-settings-keyof-dynamic-read.ts.json | 6 +++ .../8523-settings-keyof-dynamic-read.ts | 40 +++++++++++++++++++ .../8523-settings-keyof-dynamic-read.ts.json | 1 + 4 files changed, 61 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json create mode 100644 tests/corpus/8523-settings-keyof-dynamic-read.ts create mode 100644 tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 5cb9776f6..1ce873853 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1534,6 +1534,20 @@ export function lowerRecordKeyRead( recordKeyResultOk(lowerer, shape, DYN) ) declared = DYN; + // A `keyof` read over a record that holds an 'unknown' member: the + // checker's answer collapses to 'unknown' (the union of every field + // type absorbs into it), so there is no typed join to dispatch into. + // Each declared field still reads through its checked-dynamic slot — + // the very value the checker's 'unknown' can hold — so dispatch per + // declared key and answer a checked-dynamic read. The key type is the + // record's own `keyof`, so the answer is present whenever the read is. + if ( + !declared && + (lowerer.typeOf(expr).flags & ts.TypeFlags.Unknown) !== 0 && + !shape.indexValue && + recordKeyResultOk(lowerer, shape, DYN) + ) + declared = DYN; if (!declared) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json new file mode 100644 index 000000000..e42bdab21 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8523-settings-keyof-dynamic-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8523-settings-keyof-dynamic-read.ts b/tests/corpus/8523-settings-keyof-dynamic-read.ts new file mode 100644 index 000000000..d0460d6df --- /dev/null +++ b/tests/corpus/8523-settings-keyof-dynamic-read.ts @@ -0,0 +1,40 @@ +// A `keyof` read over a record that holds an `unknown` member: the +// checker's answer collapses to `unknown` (the union of every field type +// absorbs into it), so there is no typed join. The read dispatches per +// declared key through the checked-dynamic slot instead. +interface Retry { + baseDelayMs?: number; + enabled?: boolean; +} +interface Settings { + $schema?: string; + enabledModels?: string[]; + branchSummary: unknown; + fullscreenWheelScrollLines?: number | string; + modelThinkingLevels?: { [key: string]: string }; + retry?: Retry; + outputPad?: number; +} +function read(settings: Settings, key: keyof Settings): unknown { + return settings[key]; +} +const settings: Settings = { + branchSummary: { note: "x", count: 2 }, + outputPad: 4, + enabledModels: ["m"], + fullscreenWheelScrollLines: "wide", + modelThinkingLevels: { high: "big" }, + retry: { enabled: true }, +}; +const keys: (keyof Settings)[] = [ + "outputPad", + "enabledModels", + "branchSummary", + "retry", + "$schema", + "fullscreenWheelScrollLines", +]; +for (const key of keys) { + const value = read(settings, key); + console.log(key, typeof value, value === undefined ? "absent" : JSON.stringify(value)); +} diff --git a/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json b/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json new file mode 100644 index 000000000..10b67e419 --- /dev/null +++ b/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2700 } From 94036b088bbfa0997a75bfc30b06096ae059b6b1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:12 -0700 Subject: [PATCH 096/193] Narrow `.filter` predicate results to structural types, not just arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A written type predicate usually names FEWER members than the receiver's element has — `(c): c is { text: string } => ...` over a block that also carries a `textSignature` — and may name a structural type that is no arm of the element union at all. Both shapes refused, so `commitments.filter(pred)` over a union of records with differing members did not compile. Record targets now convert when their members are a SUBSET of the source's: each target member is read and converted, the copy keeps the source's keys in the source's order, and a member the source lacks still has no slot to read and stays refused. When no union arm matches the predicate's type, `structuralUnionConversion` converts tag by tag: each arm the target can be read from returns its converted value and an arm with no such read throws the same checked TypeError a lying predicate throws, so the checked extraction still traps rather than silently passing a foreign value through. Corpus 8524 pins the subset-record and structural-union conversions; `narrow-guard-traps.test.ts` pins the TypeError a predicate that returns true for a foreign value raises. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/filter-predicates.ts | 57 ++++++++++++++++++- .../compiler/test/narrow-guard-traps.test.ts | 26 +++++++++ ...524-filter-predicate-subset-record.ts.json | 6 ++ .../8524-filter-predicate-subset-record.ts | 50 ++++++++++++++++ ...524-filter-predicate-subset-record.ts.json | 1 + 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json create mode 100644 tests/corpus/8524-filter-predicate-subset-record.ts create mode 100644 tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 3934edbf0..e960bbf6b 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -161,7 +161,13 @@ function conversionBody( target.indexValue ) return null; - if (source.fields.length !== target.fields.length) return null; + // A written predicate usually names FEWER members than the receiver's + // element has (`(c): c is { text: string } => ...` over a block with a + // `textSignature` and a `type`): the target's members are a subset of + // the source's, each read and converted, and the copy keeps the + // source's keys in the source's order. A member the source lacks has + // no slot to read, so it stays refused. + if (target.fields.length > source.fields.length) return null; const fields: { name: string; value: IrExpr }[] = []; for (const field of target.fields) { const own = source.fields.find((f) => f.name === field.name); @@ -214,7 +220,13 @@ function keepAbsence( } /** A union value to one of its arms, or to a union of some of its arms: - * each arm the target holds returns, any other arm throws. */ + * each arm the target holds returns, any other arm throws. + * + * A written predicate may name a structural type that is no arm at all + * (`(c): c is { text: string; type: string } => ...` over the block union + * above, whose text arm also carries `textSignature`): when no arm matches + * exactly, each arm the target can be read from converts by its own shape, + * tag by tag, and an arm with no such read throws. */ function unionConversion( lowerer: Lowerer, input: IrExpr, @@ -250,6 +262,8 @@ function unionConversion( }); } // Every arm the target names must be one of the union's own arms. + if (kept === 0 && to.kind !== "union") + return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc); if (kept === 0 || kept !== targetArms.length) return null; body.push( typeError( @@ -259,3 +273,42 @@ function unionConversion( ); return body; } + +/** A union value to a structural type that is no arm: each arm the target + * can be read from returns its converted value, any other arm throws. */ +function structuralUnionConversion( + lowerer: Lowerer, + input: IrExpr, + unionId: string, + to: IrType, + typeText: string, + loc: SrcLoc, +): IrStmt[] | null { + const source = lowerer.unions.get(unionId); + if (!source) return null; + const body: IrStmt[] = []; + let kept = 0; + for (let tag = 0; tag < source.arms.length; tag++) { + const arm = source.arms[tag]!; + if (isUnitType(arm)) continue; + const payload: IrExpr = { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc }; + const value = converted(lowerer, payload, arm, to, loc); + if (value === null) continue; + kept++; + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc }, + then: [{ kind: "return", value, loc }], + else_: null, + loc, + }); + } + if (kept === 0) return null; + body.push( + typeError( + `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, + loc, + ), + ); + return body; +} diff --git a/packages/compiler/test/narrow-guard-traps.test.ts b/packages/compiler/test/narrow-guard-traps.test.ts index be89ad341..01508723e 100644 --- a/packages/compiler/test/narrow-guard-traps.test.ts +++ b/packages/compiler/test/narrow-guard-traps.test.ts @@ -70,3 +70,29 @@ for (const v of inputs) console.log(width(v)); expect(result.stderr.toString()).toBe(UNION_TRAP); }, ); + +test.skipIf(process.platform === "win32")( + "a lying filter predicate throws the checked-extraction TypeError", + async () => { + const result = await run(` +interface Cat { + kind: "cat"; + meows: number; +} +interface Dog { + kind: "dog"; +} +type Pet = Cat | Dog; +const pets: Pet[] = [{ kind: "cat", meows: 2 }, { kind: "dog" }]; +const alwaysCat = (p: Pet): p is Cat => p !== undefined; +try { + console.log("kept", pets.filter(alwaysCat).length); +} catch (e) { + console.log("caught", (e as Error).name); +} +`); + expect(result.error).toBeUndefined(); + // Node keeps the Dog and reports 2; the checked projection throws on it. + expect(result.stdout.toString()).toBe("caught TypeError\n"); + }, +); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json new file mode 100644 index 000000000..98784a0d4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8524-filter-predicate-subset-record.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8524-filter-predicate-subset-record.ts b/tests/corpus/8524-filter-predicate-subset-record.ts new file mode 100644 index 000000000..01b693071 --- /dev/null +++ b/tests/corpus/8524-filter-predicate-subset-record.ts @@ -0,0 +1,50 @@ +// A written type predicate usually names FEWER members than the receiver's +// element (`(b): b is { type: "text"; text: string }` over a block that +// also carries `textSignature`): the predicate's record is no arm of the +// element union, so each kept element converts to it by its own arm, tag +// by tag. An arm the predicate's record cannot be read from throws the +// checked TypeError, because a predicate that returned true for it lied. +type Block = + | { type: "image"; data: string; mimeType: string } + | { type: "text"; text: string; textSignature?: string }; + +const blocks: Block[] = [ + { type: "text", text: "first" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + { type: "text", text: "second", textSignature: "sig" }, +]; + +function texts(list: Block[]): { type: "text"; text: string }[] { + return list.filter((b): b is { type: "text"; text: string } => b.type === "text"); +} +console.log("texts", texts(blocks).map((t) => `${t.type}:${t.text}`).join(",")); + +// The same narrowing through a guard function passed as a value. +function isText(b: Block): b is { type: "text"; text: string } { + return b.type === "text"; +} +console.log("guarded", blocks.filter(isText).length); + +// Every arm converts when the predicate names members all of them carry: +// each kept element is read field by field into the predicate's record. +interface A { + tag: "a"; + n: number; +} +interface B { + tag: "b"; + n: number; +} +function numbers(list: (A | B)[]): { tag: "a" | "b"; n: number }[] { + return list.filter((v): v is { tag: "a" | "b"; n: number } => v.n > 0); +} +console.log( + "numbers", + numbers([ + { tag: "a", n: 1 }, + { tag: "b", n: 0 }, + { tag: "a", n: 3 }, + ]) + .map((v) => `${v.tag}${v.n}`) + .join(","), +); diff --git a/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json b/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json new file mode 100644 index 000000000..03a29dd70 --- /dev/null +++ b/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2800 } From 00467fdcf64923b5bf7b602cf8cd96bb9ebef404 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:17 -0700 Subject: [PATCH 097/193] Append a `flatMap` callback's TUPLE result arms in order A block-bodied callback whose enclosing call has a contextual array type infers a tuple union rather than an array union: `return pair ? [pair] : []` in a `f(): T[]` infers `[] | [pair]`. The flatten plan classified only array arms, so such a callback refused. The empty-arm detection now recognizes the empty tuple (a bare `[]` arm under a contextual array type), and the plan gains a `tuple` arm for a one-field-or-more tuple whose fields all contribute: each field is appended in order, exactly as an array arm's elements are. `checker. isArrayType` is false for tuples, so the arm scan accepts `isTupleType` too. Corpus 8525 pins the tuple arm, a two-field tuple arm, and an array arm beside a scalar arm. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-callbacks.ts | 43 ++++++++++++++--- .../corpus/8525-flatmap-tuple-arms.ts.json | 6 +++ tests/corpus/8525-flatmap-tuple-arms.ts | 46 +++++++++++++++++++ .../8525-flatmap-tuple-arms.ts.json | 1 + 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json create mode 100644 tests/corpus/8525-flatmap-tuple-arms.ts create mode 100644 tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 1224bce3e..97fc18928 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -1822,10 +1822,10 @@ export function lowerArrayFlatMapCall( /** How each arm of a flatMap callback's union result contributes to the * output: an array arm appends its elements, an array arm the checker * types `never[]` (an empty literal such as `s ? s : []`) appends nothing, - * and any other arm appends itself. */ + * a tuple arm appends its fields, and any other arm appends itself. */ interface FlatMapUnionPlan { outElem: IrType; - arms: ("spread" | "empty" | "value")[]; + arms: ("spread" | "empty" | "tuple" | "value")[]; } /** The plan for a callback returning `T | U[]`: every non-array arm must @@ -1843,15 +1843,18 @@ function flatMapUnionPlan( const outElem = out.elem; const arms = lowerer.unions.get(fnRet.unionId)?.arms; if (!arms) return null; - // The `never[]` constituents of the callback's checked result. + // The constituents of the callback's checked result that can hold no + // element: a `never[]` arm, and the empty tuple a bare `[]` arm infers + // when the enclosing call has a contextual array type (`return x ? [a] + // : []` in a `f(): T[]`). const emptyArms: IrType[] = []; const signature = lowerer.checker.getCallSignatures(lowerer.typeOf(argNode))[0]; if (signature) { const ret = lowerer.checker.getReturnTypeOfSignature(signature); for (const part of ret.isUnionType() ? ts.constituentTypes(ret) : [ret]) { - if (!lowerer.checker.isArrayType(part)) continue; - const [element] = lowerer.checker.getTypeArguments(part as ts.TypeReference); - if (element === undefined || (element.flags & ts.TypeFlags.Never) === 0) continue; + if (!lowerer.checker.isArrayType(part) && !lowerer.checker.isTupleType(part)) continue; + const element = lowerer.checker.getTypeArguments(part as ts.TypeReference)[0]; + if (element !== undefined && (element.flags & ts.TypeFlags.Never) === 0) continue; const mapped = lowerer.mapTypeOf(part); if (mapped) emptyArms.push(mapped); } @@ -1863,12 +1866,19 @@ function flatMapUnionPlan( if (outElem.kind !== "union") return false; return lowerer.armTag(outElem.unionId, t) >= 0; }; + const tupleConverts = (arm: IrType & { kind: "record" }): boolean => + (lowerer.shapes.get(arm.shapeId)?.fields ?? []).every((f) => converts(f.type)); const plan: FlatMapUnionPlan["arms"] = []; for (const arm of arms) { if (arm.kind === "array" && emptyArms.some((e) => typeEquals(e, arm))) plan.push("empty"); // An array arm copies its elements as they are stored, a present // undefined included, so its element type is the result's. else if (arm.kind === "array" && typeEquals(arm.elem, outElem)) plan.push("spread"); + // A contextual array type makes a multi-statement body's `[pair]` + // infer as a one-element TUPLE (`return x ? [pair] : []`), whose + // fields contribute in order. + else if (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple && tupleConverts(arm)) + plan.push("tuple"); else if (arm.kind !== "array" && converts(arm)) plan.push("value"); else return null; } @@ -1924,6 +1934,27 @@ function flatMapUnionHelper( loc, }; if (plan.arms[tag] === "empty") return []; + if (plan.arms[tag] === "tuple") { + const tuple = arm as IrType & { kind: "record" }; + return (lowerer.shapes.get(tuple.shapeId)?.fields ?? []).map((field) => { + const read: IrExpr = { + kind: "recordGet", + obj: narrowed, + shapeId: tuple.shapeId, + field: field.name, + type: field.type, + loc, + }; + return arrayValueStore( + lowerer, + outRef(), + outLength(), + lowerer.coerceToExpected(read, plan.outElem), + plan.outElem, + loc, + ); + }); + } if (plan.arms[tag] === "value") { // A unit arm's value is the unit itself, which has no narrowed // representation. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json new file mode 100644 index 000000000..ab705227d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8525-flatmap-tuple-arms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8525-flatmap-tuple-arms.ts b/tests/corpus/8525-flatmap-tuple-arms.ts new file mode 100644 index 000000000..b4a1d72f7 --- /dev/null +++ b/tests/corpus/8525-flatmap-tuple-arms.ts @@ -0,0 +1,46 @@ +// A `flatMap` callback whose body is a block and whose enclosing call has a +// contextual array type infers a TUPLE union (`return v ? [pair] : []` +// infers `[] | [pair]`), so one arm is a tuple rather than an array. The +// loop appends a tuple arm's fields in order, exactly as it appends an array +// arm's elements, and an empty arm appends nothing. +interface Namespace { + name: string; + description?: string; +} +interface Tool { + name: string; + label?: string; +} +interface Loadout { + callable: Tool[]; + getNamespace(name: string): Namespace | undefined; +} + +function pairsOf(loadout: Loadout): (readonly [string, Namespace])[] { + return loadout.callable.flatMap((tool) => { + const namespace = loadout.getNamespace(tool.name); + return namespace ? [[tool.name, namespace] as const] : []; + }); +} + +const loadout: Loadout = { + callable: [{ name: "docs" }, { name: "skip", label: "S" }, { name: "notes" }], + getNamespace: (name) => + name === "skip" ? undefined : { name: name.toUpperCase(), description: `${name} ns` }, +}; +const pairs = pairsOf(loadout); +console.log("pairs", pairs.length); +for (const [key, namespace] of pairs) console.log("pair", key, namespace.name, namespace.description); +console.log("map size", new Map(pairsOf(loadout)).size); + +// A two-field tuple arm contributes both fields, in order. +function doubled(words: (string | undefined)[]): string[] { + return words.flatMap((word) => (word ? [word, word.toUpperCase()] : [])); +} +console.log("doubled", doubled(["a", undefined, "bc"]).join(",")); + +// An array arm beside a scalar arm keeps the per-value flatten decision. +function pieces(parts: (string | string[])[]): string[] { + return parts.flatMap((part) => (Array.isArray(part) ? part : [part])); +} +console.log("pieces", pieces(["x", ["y", "z"], "w"]).join("-")); diff --git a/tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json b/tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json new file mode 100644 index 000000000..f0854172c --- /dev/null +++ b/tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 2400 } From 1ed5ba53aa1dbc1c52119fb5000ba9c31e508364 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:23 -0700 Subject: [PATCH 098/193] Lower `u || d` where the checker narrowed `u` to one of its own arms `return typeof options === "string" ? {...} : options || {}` refused: flow narrowing ruled the string arm out, but the stored value keeps the wider union, so the logical operator saw a union-typed left operand outside a condition. When the checker's result is one of the left value's own arms, test the value in its own union and extract the result arm through the checked single-arm narrowing: the falsy arm takes the default, which keeps its lazy evaluation, and a value of a ruled-out arm traps rather than masquerading as the result type. Corpus 8526 pins the narrowed `||` default over an optional interface, a union of records, and lazy default evaluation. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/expressions/binary.ts | 38 +++++++++++++++++ .../corpus/8526-union-logical-default.ts.json | 6 +++ tests/corpus/8526-union-logical-default.ts | 41 +++++++++++++++++++ .../8526-union-logical-default.ts.json | 1 + 4 files changed, 86 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json create mode 100644 tests/corpus/8526-union-logical-default.ts create mode 100644 tests/harness/corpus-costs/8526-union-logical-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..d4034ba8e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -2964,6 +2964,44 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE loc, }; } + // `u || d` whose checker result is one of u's own arms while u keeps + // arms flow narrowing ruled out (`typeof o === "string" ? ... : o || + // {}` leaves the string arm in the stored union). Test u in its own + // union and extract the result arm through the checked narrowing: a + // value of a ruled-out arm traps rather than masquerading as the + // result type. + if (op === ts.SyntaxKind.BarBarToken && left.type.kind === "union") { + const def = lowerer.unions.get(left.type.unionId); + if ( + target !== null && + def !== undefined && + def.arms.some(isUnitType) && + lowerer.armTag(left.type.unionId, target) >= 0 + ) { + const helper = lowerer.narrowedArmHelper(left.type.unionId, target, loc); + if (helper) { + lowerer.requireTruthyUnion(left.type.unionId, expr); + const stmts: IrStmt[] = []; + let stable: IrExpr = left; + if (!isSafeToRepeat(left)) { + const local = lowerer.declareHiddenLocal("%or", left.type); + stmts.push({ kind: "varDecl", localId: local.id, init: left, loc }); + stable = varRef(local.id, left.type, loc); + } + const result: IrExpr = { + kind: "ternary", + cond: lowerer.ensureBool(stable, expr.left), + then: { kind: "call", callee: helper, args: [stable], type: target, loc }, + else_: lowerer.lowerExprExpecting(expr.right, target), + type: target, + loc, + }; + return stmts.length === 0 + ? result + : { kind: "seqExpr", stmts, result, type: target, loc }; + } + } + } lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json new file mode 100644 index 000000000..425c1eed4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8526-union-logical-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8526-union-logical-default.ts b/tests/corpus/8526-union-logical-default.ts new file mode 100644 index 000000000..0980b4983 --- /dev/null +++ b/tests/corpus/8526-union-logical-default.ts @@ -0,0 +1,41 @@ +// `o || {}` where flow narrowing ruled out the other arm of `o`'s type: +// the stored value keeps its wider union, so the default tests the value in +// its own union and extracts the checker's arm through the checked +// narrowing. The falsy arm takes the default; an object argument is the +// result itself. +interface ExportOptions { + outputPath?: string; + theme?: string; + inlineImages?: boolean; +} + +function fromPath(options?: ExportOptions | string): ExportOptions { + return typeof options === "string" ? { outputPath: options } : options || {}; +} + +const names = (o: ExportOptions): string => `${o.outputPath ?? "-"}/${o.theme ?? "-"}/${o.inlineImages ?? "-"}`; +console.log("none", names(fromPath(undefined))); +console.log("path", names(fromPath("out.html"))); +console.log("object", names(fromPath({ outputPath: "p", theme: "dark", inlineImages: true }))); +console.log("empty", JSON.stringify(fromPath({}))); + +// The same shape over a union of records with a default object. +interface Cursor { + line: number; + column: number; +} +function cursorAt(where: Cursor | undefined): Cursor { + return where || { line: 1, column: 1 }; +} +console.log("cursor", JSON.stringify(cursorAt(undefined)), JSON.stringify(cursorAt({ line: 4, column: 2 }))); + +// The default is evaluated only when the left is falsy. +let defaults = 0; +function counted(): Cursor { + defaults++; + return { line: 0, column: 0 }; +} +const base: Cursor | undefined = { line: 2, column: 3 }; +console.log("lazy", JSON.stringify(base || counted()), defaults); +const missing: Cursor | undefined = undefined; +console.log("fallback", JSON.stringify(missing || counted()), defaults); diff --git a/tests/harness/corpus-costs/8526-union-logical-default.ts.json b/tests/harness/corpus-costs/8526-union-logical-default.ts.json new file mode 100644 index 000000000..61de013e5 --- /dev/null +++ b/tests/harness/corpus-costs/8526-union-logical-default.ts.json @@ -0,0 +1 @@ +{ "plain": 1450, "san": 2350 } From 04409081f990afbc4ff729346cc17217f89ec4d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:24:35 -0700 Subject: [PATCH 099/193] Lower chain-guarded union reads from the stored union Inside an optional-chain body the checker types the guarded receiver by its non-nullish narrowing, often one record of a discriminated union, while the bound chain value still carries the whole present union: a runtime-optional local that crossed an `await` and a prior `stored?.type === "oauth"` return holds `ApiKeyCredential | OAuthCredential` at runtime. lowerUnionProperty gated on the spelled receiver type being a union, so the read fell through to the generic SC1090 fence ("reading 'type' from a value of type 'ApiKeyCredential'"). Take the union from the chain binding, which is the value lowerExpr is about to read; unionDisc then dispatches on the stored tag and maybeNarrow applies the checker's narrowing to the result. Adds the differential corpus program union-read-after-chain-guard and its emission test. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 24 ++++- .../union-read-after-chain-guard.ts.json | 6 ++ ...on-read-after-chain-guard-emission.test.ts | 89 +++++++++++++++++++ tests/corpus/union-read-after-chain-guard.ts | 48 ++++++++++ .../union-read-after-chain-guard.ts.json | 1 + 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json create mode 100644 packages/compiler/test/union-read-after-chain-guard-emission.test.ts create mode 100644 tests/corpus/union-read-after-chain-guard.ts create mode 100644 tests/harness/corpus-costs/union-read-after-chain-guard.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e2595f465..93ec89099 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3545,9 +3545,21 @@ export function lowerUnionProperty( expr: ts.PropertyAccessExpression, ): IrExpr | null { if (lowerer.chainBlocked(expr)) return null; - const receiverIr = + const spelled = structuralReadType(lowerer, expr.expression) ?? lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + // Inside an optional-chain body the guarded receiver is checker-typed by + // its non-nullish narrowing — often one record of a discriminated union + // — while the bound chain value still carries the whole present union + // (`stored?.type` after an earlier `stored?.type === "oauth"` return + // narrowed `stored` to `ApiKeyCredential`, though the runtime-optional + // storage still holds `ApiKeyCredential | OAuthCredential`). The union + // reader dispatches on the runtime tag, so take the union the chain + // binding hands to `lowerExpr` below instead of the spelled narrowing. + const chainRecv = lowerer.chainRecvByNode.get(expr.expression); + const chainUnion = + spelled?.kind !== "union" && chainRecv?.type.kind === "union" ? chainRecv.type : null; + const receiverIr = chainUnion ?? spelled; // The checker can rule the receiver UNREACHABLE and the assertion that // escapes it (`const x: never = event; (event as any).type` — the // `default:` branch of a switch that exhausts every arm) erases to @@ -3581,6 +3593,16 @@ export function lowerUnionProperty( // extract the instance and test its class, as typed slots do. else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value; } + // The spelled type is a narrowing, so every path below (dyn, record, + // object, array) was unreachable for this read before it took the stored + // union: answer it only when the union reader can, and otherwise hand the + // read back to the lowering the spelled type used to reach. A stored arm + // the checker ruled out that has no such member (`m1?.name` where the + // stored union still carries a `string` arm) must not turn an accepted + // program into a refusal. + if (chainUnion !== null) { + return value.type.kind === "union" ? lowerUnionValueRead(lowerer, expr, value) : null; + } // A checker-union receiver whose VALUE lowered to a plain RECORD (the // merged-signature fiction — `runner(cmd, args)` where runner joined // a structural runner type with spawnSync's, and the local adopted diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json new file mode 100644 index 000000000..223001d72 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-read-after-chain-guard.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-read-after-chain-guard-emission.test.ts b/packages/compiler/test/union-read-after-chain-guard-emission.test.ts new file mode 100644 index 000000000..993b7afd4 --- /dev/null +++ b/packages/compiler/test/union-read-after-chain-guard-emission.test.ts @@ -0,0 +1,89 @@ +// A member read on a union receiver inside an optional-chain body: the +// checker narrows the guarded receiver to ONE union arm (a discriminant +// test through `?.`), while the bound chain value still carries the whole +// present union — the runtime-optional local a prior `if (s?.kind === ...) +// return` narrowed after an `await`. The read must dispatch on the value's +// runtime tag (unionDisc), not fall through to the unsupported-property +// fence the spelled single-record type would suggest. +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, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface ApiKeyCredential { type: 'api_key'; key?: string }", + "interface OAuthCredential { type: 'oauth'; refresh: string; expires: number }", + "type Credential = ApiKeyCredential | OAuthCredential;", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-union-chain-read-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("a chain-guarded union read dispatches on the stored union", async () => { + const compiled = await compileToIr([ + "function load(): Promise {", + " return Promise.resolve({ type: 'api_key', key: 'k' } as Credential);", + "}", + "async function pick(): Promise {", + " const stored = await load();", + " if (stored?.type === 'oauth') return stored.expires > 0 ? 'oauth' : 'expired';", + " const credential = stored?.type === 'api_key' ? stored : undefined;", + " return credential?.type === 'api_key' ? (credential.key ?? 'none') : 'missing';", + "}", + "pick().then((answer) => console.log(answer));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // The guarded read still sees both stored arms; the tag switch is the + // shape that composes with the discriminant comparison. + const pick = bodies(compiled.module!, "pick"); + expect(pick).toContain('"unionDisc"'); + // The runtime-optional local crosses the await, so the presence test is + // the chain's own guard, not a checked extraction trap on every read. + expect(pick).not.toContain('"unsupported"'); +}); + +test("a chain-guarded read of a non-discriminant field reads the survivor", async () => { + const compiled = await compileToIr([ + "function only(c: Credential | undefined): string {", + " return c?.type === 'oauth' ? c.refresh : 'api';", + "}", + "console.log(only({ type: 'oauth', refresh: 'r', expires: 1 }));", + "console.log(only(undefined));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + expect(bodies(compiled.module!, "only")).toContain('"refresh"'); +}); diff --git a/tests/corpus/union-read-after-chain-guard.ts b/tests/corpus/union-read-after-chain-guard.ts new file mode 100644 index 000000000..f7c454f8b --- /dev/null +++ b/tests/corpus/union-read-after-chain-guard.ts @@ -0,0 +1,48 @@ +// A discriminated union held across an `await`: the early `stored?.type === +// "oauth"` return narrows the checker's `stored` to the api-key arm, but the +// runtime-optional storage still holds both arms. The later `s?.type` reads +// dispatch on the stored value's tag, so every credential shape answers as in +// Node — including the arm the earlier guard ruled out at runtime. +type ProviderEnv = Record; +interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: ProviderEnv; +} +interface OAuthCredentials { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +} +interface OAuthCredential extends OAuthCredentials { + type: "oauth"; +} +type Credential = ApiKeyCredential | OAuthCredential; + +const stored: (Credential | undefined)[] = [ + { type: "api_key", key: "sk-live", env: { REGION: "eu" } }, + { type: "oauth", refresh: "r1", access: "a1", expires: 42 }, + { type: "api_key" }, + undefined, +]; + +function load(i: number): Promise { + return Promise.resolve(stored[i]); +} + +async function describe(i: number): Promise { + const found = await load(i); + if (found?.type === "oauth") { + return found.expires > 10 ? `oauth ${found.refresh}/${found.access}` : "oauth expired"; + } + const credential = found?.type === "api_key" ? found : undefined; + const key = credential?.type === "api_key" ? (credential.key ?? "") : ""; + const region = credential?.env?.REGION ?? ""; + return `api ${key} ${region}`; +} + +async function main(): Promise { + for (let i = 0; i < stored.length; i++) console.log(i, await describe(i)); +} +main(); diff --git a/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json b/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json new file mode 100644 index 000000000..6bbc1dfa2 --- /dev/null +++ b/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2300 } From b200bde558cc85e91cbcf212009bf880f1de9f66 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:49:14 -0700 Subject: [PATCH 100/193] Read a generic constraint member the instantiation omits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `options.sessionId` inside a generic function whose type parameter's constraint declares `sessionId?: string` fenced with SC1090 when the call site's options record has no such slot: function applyAuth & Transforms>( model: TModel, options: TOptions | undefined) { const sessionId = options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined; } applyAuth(model, { apiKey, azureApiVersion }); The checker admits the read through the constraint (and, with the `in` guard, through a synthetic `T & Record<"sessionId", unknown>`), while the instantiated record — the value's actual layout — stores no such field. `fieldTarget` found no record slot and the read fell through to the generic unsupported-property fence, even though Node simply reads the property off the object it has: undefined when the caller omitted it, the extra property when a JavaScript caller supplied one. fieldTarget now recognizes a generic receiver (a type parameter, or an `in`-narrowed intersection that contains one) whose stored record lacks the member, and lowers the read as the checked-dynamic keyed read of that record value, entering the static code at the type the checker gave the read. Reads that keep a slot take their record get as before, and the programs this path opens did not compile at all before, so typed code keeps its representation. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 77 ++++++++++++++++ ...ic-constraint-member-read-emission.test.ts | 87 +++++++++++++++++++ .../generic-constraint-member-read.ts.json | 6 ++ .../corpus/generic-constraint-member-read.ts | 54 ++++++++++++ .../generic-constraint-member-read.ts.json | 1 + 5 files changed, 225 insertions(+) create mode 100644 packages/compiler/test/generic-constraint-member-read-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json create mode 100644 tests/corpus/generic-constraint-member-read.ts create mode 100644 tests/harness/corpus-costs/generic-constraint-member-read.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 93ec89099..3c0a8de3c 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4315,6 +4315,34 @@ function checkedDynamicValue(expr: IrExpr): IrExpr | null { return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null; } +/** A GENERIC receiver type: a type parameter, or a narrowing of one that + * intersects it (`"k" in options` refines a type variable to + * `T & Record<"k", unknown>`). Its stored layout is whatever the call site + * instantiated it with, and the CONSTRAINT's members are not necessarily + * stored there. */ +function typeParameterReceiver(t: ts.Type): boolean { + try { + if ((t.flags & ts.TypeFlags.TypeParameter) !== 0) return true; + return ( + (t.flags & ts.TypeFlags.Intersection) !== 0 && + ts.constituentTypes(t).some((part) => (part.flags & ts.TypeFlags.TypeParameter) !== 0) + ); + } catch (e) { + if (ts.isUnreadableConstituentsPanic(e)) return false; + throw e; + } +} + +/** Whether `access` is only READ (not the target of an assignment, a + * delete, or an increment). */ +function readOnlyAccess(access: ts.PropertyAccessExpression): boolean { + const parent = access.parent; + if (parent === undefined) return true; + if (ts.isBinaryExpression(parent) && parent.left === access) return false; + if (ts.isDeleteExpression(parent)) return false; + return !ts.isPrefixUnaryExpression(parent) && !ts.isPostfixUnaryExpression(parent); +} + export function fieldTarget( lowerer: Lowerer, access: ts.PropertyAccessExpression, @@ -4441,6 +4469,55 @@ export function fieldTarget( lowerer.lowerExpr(access.expression), ); } + // A generic receiver's member the INSTANTIATION cannot store: the type + // parameter's constraint declares the member (so tsc admits the read), + // while the record the parameter was instantiated with has no such slot — + // a generic options bag read through its constraint (`options.sessionId` + // under an `"sessionId" in options` guard) at a call site whose options + // literal omits the optional member. Node reads the property off the + // object it actually has: undefined when the caller omitted it, the extra + // property when a JavaScript caller supplied one. So the read is the + // checked-dynamic keyed read of the stored record, entering the static + // code at the type the checker gave the read. + if ( + !access.questionDotToken && + readOnlyAccess(access) && + typeParameterReceiver(lowerer.typeOf(access.expression)) + ) { + const storedIr = stored ?? narrowed; + const arm = storedIr?.kind === "union" ? lowerer.stripUndefinedArm(storedIr) : storedIr; + const shape = arm?.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined; + const admitted = lowerer.mapTypeOf(lowerer.typeOf(access)); + if ( + shape && + !shape.tuple && + !shape.indexValue && + !shape.fields.some((f) => f.name === access.name.text) && + admitted && + admitted.kind !== "void" + ) { + let obj = lowerer.lowerExpr(access.expression); + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { + const present = lowerer.stripUndefinedArm(obj.type); + obj = + present.kind === "record" + ? (lowerer.runtimeOptionalPropertyReceiver( + access.expression, + obj, + present, + access.name.text, + ) ?? obj) + : lowerer.maybeNarrow(obj, access.expression); + } + if (obj.type.kind !== "record") return null; + return { + container: "dynamic", + obj: lowerer.coerceToExpected(obj, DYN), + field: access.name.text, + fieldType: admitted, + }; + } + } if (receiverIr?.kind === "record") { const shape = lowerer.shapes.get(receiverIr.shapeId); const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type; diff --git a/packages/compiler/test/generic-constraint-member-read-emission.test.ts b/packages/compiler/test/generic-constraint-member-read-emission.test.ts new file mode 100644 index 000000000..bb7fa7930 --- /dev/null +++ b/packages/compiler/test/generic-constraint-member-read-emission.test.ts @@ -0,0 +1,87 @@ +// A member the checker admits only through a type parameter's constraint +// while the instantiation's record has no slot for it (`options.sessionId` +// under an `"sessionId" in options` guard, at a call site whose options +// literal omits the optional member): the read must lower to the +// checked-dynamic keyed read of the stored record — the object's own +// property, undefined when absent — instead of fencing. +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, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface RequestOptions { model?: TModel; apiKey?: string; sessionId?: string }", + "interface Transforms {", + " transformHeaders?: (headers: Record) => Record;", + "}", + "function sessionOf & Transforms>(", + " options: TOptions | undefined,", + "): string | undefined {", + " return options && 'sessionId' in options && typeof options.sessionId === 'string'", + " ? options.sessionId", + " : undefined;", + "}", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + lines: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-generic-constraint-read-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...lines].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("an omitted constraint member reads dynamically off the stored record", async () => { + const compiled = await compileToIr([ + "const withoutKey = { apiKey: 'k' };", + "console.log(String(sessionOf<{ id: string }, typeof withoutKey>(withoutKey)));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const body = bodies(compiled.module!, "sessionOf"); + // The stored record has no `sessionId` slot, so the read is the + // checked-dynamic keyed read of the value (dynFrom → key read → check). + expect(body).toContain('"dynFrom"'); + expect(body).toContain('"dynCheck"'); + expect(body).not.toContain('"unsupported"'); +}); + +test("a constraint member the instantiation stores still reads its slot", async () => { + const compiled = await compileToIr([ + "const withKey = { apiKey: 'k', sessionId: 's-1' };", + "console.log(String(sessionOf<{ id: string }, typeof withKey>(withKey)));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const body = bodies(compiled.module!, "sessionOf"); + expect(body).toContain('"recordGet"'); + expect(body).toContain('"sessionId"'); + expect(body).not.toContain('"dynFrom"'); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json new file mode 100644 index 000000000..903bc4c50 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-constraint-member-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-constraint-member-read.ts b/tests/corpus/generic-constraint-member-read.ts new file mode 100644 index 000000000..e958097fd --- /dev/null +++ b/tests/corpus/generic-constraint-member-read.ts @@ -0,0 +1,54 @@ +// A generic function reads OPTIONAL members its constraint declares while the +// call site's own options record omits them (`options.sessionId` under an +// `"sessionId" in options` guard). Node reads the property off the object it +// actually has, so a caller that omits the member answers undefined and one +// that supplies it answers the value — for every instantiation of the +// function, which the program pins one by one. +interface RequestOptions { + model?: TModel; + apiKey?: string; + sessionId?: string; +} +interface Transforms { + transformHeaders?: (headers: Record) => Record; +} + +function describeOptions & Transforms>( + options: TOptions | undefined, +): string { + const sessionId = + options && "sessionId" in options && typeof options.sessionId === "string" + ? options.sessionId + : ""; + const apiKey = + options && "apiKey" in options && typeof options.apiKey === "string" ? options.apiKey : ""; + return `session=${sessionId} key=${apiKey}`; +} + +const withSession = { apiKey: "sk-1", sessionId: "sess-1" }; +const withoutSession = { apiKey: "sk-2" }; +const onlySession = { sessionId: "sess-3" }; +const empty = {}; + +console.log(describeOptions<{ id: string }, typeof withSession>(withSession)); +console.log(describeOptions<{ id: string }, typeof withoutSession>(withoutSession)); +console.log(describeOptions<{ id: number }, typeof onlySession>(onlySession)); +console.log(describeOptions<{ id: number }, typeof empty>(empty)); +console.log(describeOptions<{ id: string }, Transforms>(undefined)); + +// The same read through a generic method on a class, whose instantiation is +// fixed by the call site's own argument type. +class OptionsReader & Transforms> { + read(options: TOptions | undefined): string { + return options && "sessionId" in options && typeof options.sessionId === "string" + ? options.sessionId + : ""; + } +} +const keyedReader = new OptionsReader(); +const bareReader = new OptionsReader(); +console.log( + keyedReader.read(withSession), + bareReader.read(withoutSession), + bareReader.read(undefined), +); diff --git a/tests/harness/corpus-costs/generic-constraint-member-read.ts.json b/tests/harness/corpus-costs/generic-constraint-member-read.ts.json new file mode 100644 index 000000000..cb50f798b --- /dev/null +++ b/tests/harness/corpus-costs/generic-constraint-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3400 } From 3c5b9eadd2eba1e2db0aae47ac2f2c0753986c69 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:15:33 -0700 Subject: [PATCH 101/193] Lift a record field whose function value narrows its parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record width conversion copies a function-valued field when the field's signature adapts (cleanFuncAdaptable). That gate asked coercibleValue of each parameter pair, which excludes the checked-dynamic extraction and the structural width the parameter conversion actually performs, so a definition whose `execute`/`renderCall` take a concrete parameter record could not complete a slot declaring `unknown` members — the SC2002 `field 'execute'` / `field 'renderCall'` shape that dominates the census's tool-definition sites. The gate now measures what funcCoerceAdapter converts: cleanParamPair for parameters (so a callback parameter recurses through the same rule one function type down) and cleanResult for the result (a structural width, which carries the promise payload widening, and the void-into-unknown drop). A recursion guard keyed in coercions.planning keeps structurally recursive function aliases from looping, the coinductive answer recordWidthPlan already gives its own back-edges. A parameter that narrows a dynamic member to a record is the checked-dynamic extraction, the same conversion a cast performs: an argument of the wrong shape throws from the adapter rather than reading undefined, as with any checked conversion in the language. The gate widens admission only: a pair the old gate refused was an SC2002 error, so programs that already compiled keep byte-identical IR. Co-Authored-By: Claude Opus 5.5 --- .../lowering/coercions/function-adapters.ts | 44 +++++-- .../corpus/record-func-field-width.ts.json | 6 + tests/corpus/record-func-field-width.ts | 109 ++++++++++++++++++ .../record-func-field-width.ts.json | 1 + 4 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json create mode 100644 tests/corpus/record-func-field-width.ts create mode 100644 tests/harness/corpus-costs/record-func-field-width.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 50366eeb6..322cc09fa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -129,7 +129,13 @@ export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean * parameters are ignored. A source with MORE parameters than the slot is * clean when every extra trailing one is omittable — the adapter passes * that absent value explicitly (funcCoerceAdapter's omittedParams), the - * same way a call through the slot would. */ + * same way a call through the slot would. A parameter the slot coercion + * converts through a STRUCTURAL width counts (cleanParamPair), which + * recurses through a nested function type: a callback parameter of the + * value taking a narrower record where the slot's takes its `unknown` view + * converts the argument the same way a top-level call does. The result side + * likewise accepts what funcCoerceAdapter converts (cleanResult), including + * a promise whose payload widens. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, @@ -137,15 +143,35 @@ export function cleanFuncAdaptable( ): boolean { if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; - for (let i = dst.params.length; i < src.params.length; i++) { - if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + // Recursive function types (`type Handler = (h: Handler) => void`) reach + // the same pair again one parameter down: assume it converts, the same + // coinductive answer recordWidthPlan gives its own back-edges. + const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`; + if (lowerer.coercions.planning.has(key)) return true; + lowerer.coercions.planning.add(key); + try { + for (let i = dst.params.length; i < src.params.length; i++) { + if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { + if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false; + } + return cleanResult(lowerer, src.ret, dst.ret); + } finally { + lowerer.coercions.planning.delete(key); } - for (let i = 0; i < src.params.length && i < dst.params.length; i++) { - if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; - } - if (dst.ret.kind === "void") return true; - if (lowerer.coercibleValue(src.ret, dst.ret)) return true; - return src.ret.kind === "void" && dst.ret.kind === "dyn"; +} + +/** Whether the slot's coercion converts the value's RESULT without a + * trap-only path: the dispositions funcCoerceAdapter's result side accepts + * (a void slot drops the value; coercibleValue; a structural width, which + * includes the promise payload widening and a nested function adaptation; + * the void-into-unknown drop). */ +function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean { + if (expected.kind === "void") return true; + if (lowerer.coercibleValue(actual, expected)) return true; + if (actual.kind === "void" && expected.kind === "dyn") return true; + return lowerer.widthLiftPlan(actual, expected) !== null; } /** Whether the slot's argument type `actual` converts into the function's diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json new file mode 100644 index 000000000..36ec7deb6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-func-field-width.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-func-field-width.ts b/tests/corpus/record-func-field-width.ts new file mode 100644 index 000000000..1b9f7bd41 --- /dev/null +++ b/tests/corpus/record-func-field-width.ts @@ -0,0 +1,109 @@ +// A record field holding a function adapts when the slot's function type is a +// structural width of the field's: a parameter record whose `unknown` members +// gain shapes (the checked-dynamic extraction), a callback parameter whose +// record widens the same way one level down, and a promise payload that widens. +// +// `wrapToolDefinition` states the definition's state and details parameters as +// `any`/default `unknown`, so passing a concrete definition crosses the +// `execute` and `renderCall` fields of the record. + +interface Truncation { + content: string; + truncated: boolean; +} + +interface ToolResult { + content: { type: string; text: string }[]; + details: D; + isError: boolean | undefined; +} + +type UpdateCallback = (partial: ToolResult) => void; + +interface ToolDefinition { + name: string; + execute(id: string, onUpdate: UpdateCallback | undefined): Promise>; + renderCall(context: { state: S; label: string }): string; +} + +interface BashState { + startedAt: number; + executionStarted: boolean; +} + +interface BashDetails { + truncation: Truncation | undefined; + fullOutputPath: string | undefined; +} + +function wrapToolDefinition( + definition: ToolDefinition, +): ToolDefinition { + return { + name: definition.name, + execute: definition.execute, + renderCall: definition.renderCall, + }; +} + +function bashDefinition(name: string): ToolDefinition { + return { + name, + execute: (id, onUpdate) => { + const result: ToolResult = { + content: [{ type: "text", text: `ran ${id}` }], + details: { truncation: { content: "tail", truncated: true }, fullOutputPath: undefined }, + isError: undefined, + }; + onUpdate?.(result); + return Promise.resolve(result); + }, + renderCall: (context) => `${context.label} after ${context.state.startedAt}ms`, + }; +} + +// The `execute` field: the slot's update callback takes `details: unknown` +// where the value's takes the concrete record, and the result promise payload +// widens the same way. +function widenDetails(): ToolDefinition { + return wrapToolDefinition(bashDefinition("bash")); +} + +// The `renderCall` field: the slot's context record has `state: unknown` +// where the value's has the concrete state record. +function widenState(definition: ToolDefinition): ToolDefinition { + return wrapToolDefinition(definition); +} + +// The same crossing when the argument is an object literal carrying optional +// members the slot's shape also declares. +interface WithHints { + annotations: { readOnlyHint: boolean | undefined } | undefined; + defaultActive: boolean | undefined; +} + +function widenLiteral(): ToolDefinition { + const hints: WithHints = { annotations: { readOnlyHint: true }, defaultActive: undefined }; + const definition: ToolDefinition & WithHints = { + ...bashDefinition("literal"), + ...hints, + }; + console.log("hints:", definition.annotations?.readOnlyHint, definition.defaultActive); + return wrapToolDefinition(definition); +} + +const widened = widenDetails(); +widened + .execute("ls", (partial) => { + console.log("update:", partial.content[0]?.text, partial.isError); + }) + .then((result) => { + console.log("result:", result.details, result.isError); + }); + +const rendered = widenState(bashDefinition("grep")); +console.log("render:", rendered.renderCall({ state: { startedAt: 12, executionStarted: true }, label: "grep" })); + +const literal = widenLiteral(); +console.log("literal render:", literal.renderCall({ state: { startedAt: 3, executionStarted: false }, label: "ls" })); +console.log("name:", literal.name); diff --git a/tests/harness/corpus-costs/record-func-field-width.ts.json b/tests/harness/corpus-costs/record-func-field-width.ts.json new file mode 100644 index 000000000..31e3551a8 --- /dev/null +++ b/tests/harness/corpus-costs/record-func-field-width.ts.json @@ -0,0 +1 @@ +{ "plain": 3050, "san": 4250 } From e41fee0c4a2c591e695d4c0e74917e352f5e47ed Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:18:27 -0700 Subject: [PATCH 102/193] Fill an async result from a returned `Promise` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return fatal(...)` where `fatal` is declared `Promise` (a throw-only async helper) is well-typed — `never` converts into anything — but the returned value flattens through the returned-promise await into a void, which then fails the async function's declared record result with SC2002 (`expected '{ cancelled: boolean }', got 'void'`). The IR keeps `Promise` as a promise with a void payload, exactly like `Promise`, so the two are indistinguishable by type alone. The return now asks the TS payload type: a `never` payload means the promise never settles, so the awaited value is unreachable and fills the declared result through divergentValue — the same unreachable-value path a never-returning call already takes in a value position. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 22 +++++++++++ .../tests/corpus/promise-never-return.ts.json | 6 +++ tests/corpus/promise-never-return.ts | 38 +++++++++++++++++++ .../corpus-costs/promise-never-return.ts.json | 1 + 4 files changed, 67 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json create mode 100644 tests/corpus/promise-never-return.ts create mode 100644 tests/harness/corpus-costs/promise-never-return.ts.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..af2086a0a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -13894,6 +13894,19 @@ export class Lowerer { ); } + /** Whether `node`'s TS type is a `Promise` (the throw-only async + * function's declared result): the promise can never settle, so the value + * it would resolve to is unreachable. The IR keeps `Promise` as a + * promise with a void payload, exactly like `Promise`, so the TS + * payload type is what separates the two. */ + private promisePayloadIsNever(node: ts.Expression): boolean { + const t = this.typeOf(node); + if ((t.flags & ts.TypeFlags.Object) === 0) return false; + if (((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return false; + const args = this.checker.getTypeArguments(t as ts.TypeReference); + return args.length === 1 && (args[0]!.flags & ts.TypeFlags.Never) !== 0; + } + /** The value of `return ` (already lowered untyped) in an async or * void context: a promise flattens through the returned-promise await, * everything else converts into the declared result. */ @@ -13901,6 +13914,15 @@ export class Lowerer { if (expected.kind === "void" && e.kind === "unitLit") return null; if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") { const awaited = this.returnedPromiseAwait(e); + // A `Promise` never settles, so the awaited value is + // unreachable: it fills any result shape instead of failing the + // void-to-record conversion. + if ( + expected.kind !== "void" && + e.type.inner.kind === "void" && + this.promisePayloadIsNever(node) + ) + return this.divergentValue(awaited, expected); return this.coerceInto(node, awaited, expected); } return this.coerceInto(node, e, expected); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json new file mode 100644 index 000000000..b69caef07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-never-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/promise-never-return.ts b/tests/corpus/promise-never-return.ts new file mode 100644 index 000000000..b494ee211 --- /dev/null +++ b/tests/corpus/promise-never-return.ts @@ -0,0 +1,38 @@ +// `return fatal(...)` where `fatal` is declared `Promise`: the promise +// can never settle, so the awaited value is unreachable and fills the async +// function's record result instead of failing a void-to-record conversion. + +interface Outcome { + cancelled: boolean; +} + +async function fatal(message: string): Promise { + throw new Error(message); +} + +async function run(flag: boolean): Promise { + try { + if (flag) throw new Error(`zap ${flag}`); + return { cancelled: false }; + } catch (error: unknown) { + return fatal(error instanceof Error ? error.message : "unknown"); + } +} + +async function passthrough(): Promise { + try { + throw new Error("always"); + } catch { + return fatal("passthrough"); + } +} + +run(false) + .then((outcome) => console.log("resolved:", outcome.cancelled)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); +run(true) + .then((outcome) => console.log("resolved:", outcome.cancelled)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); +passthrough() + .then((value) => console.log("value:", value)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); diff --git a/tests/harness/corpus-costs/promise-never-return.ts.json b/tests/harness/corpus-costs/promise-never-return.ts.json new file mode 100644 index 000000000..6cf92fddb --- /dev/null +++ b/tests/harness/corpus-costs/promise-never-return.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 4100 } From 1d7ddf7391f8e9ffd081d3d0a2ff2a83262715d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:24:18 -0700 Subject: [PATCH 103/193] Lower `?.` method calls on dynamic receivers and declared-optional capture receivers `this.cache?.warm()` refused whenever the receiver lowered to a dynamic value: the checked-dynamic receiver path treated any `?.` step as unhandled chain work and bailed, even inside a chain body whose guard had already proven the receiver present. Those steps are now gated on `chainBlocked`, the canonical "is this `?.` still unhandled" test, so the guarded body is the plain read-and-call it already is, with `dynCall` throwing Node's catchable "x.y is not a function" for a present but non-callable member. The string-only and primitive-method branches drop their raw `questionDotToken` checks the same way. A capture the checker itself types with an absent arm (`T | undefined`, an optional property or parameter) also refused as a "complex" use in a logical or conditional receiver, even though the union value flows into the surrounding operator, which handles the absent arm itself. The use now lowers to a plain read when both the checked type and the recorded storage carry an undefined arm; a hidden absence keeps its refusal. Corpus 8500 and 8501 pin optional method-call receivers and `(entries ?? []).map(...)`-shaped capture receivers against Node. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/identifiers.ts | 15 ++++ .../src/frontend/lowering/lower-calls.ts | 15 ++-- ...500-optional-method-call-receivers.ts.json | 6 ++ ...nullish-optional-capture-receivers.ts.json | 6 ++ .../8500-optional-method-call-receivers.ts | 79 +++++++++++++++++++ ...8501-nullish-optional-capture-receivers.ts | 28 +++++++ ...500-optional-method-call-receivers.ts.json | 1 + ...nullish-optional-capture-receivers.ts.json | 1 + 8 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json create mode 100644 tests/corpus/8500-optional-method-call-receivers.ts create mode 100644 tests/corpus/8501-nullish-optional-capture-receivers.ts create mode 100644 tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json create mode 100644 tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..4767f9b7b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -236,6 +236,21 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: if (active || captured) { const use = runtimeOptionalUseOf(expr); if (use?.complex) { + // A DECLARED absent arm is honest storage: when the checker itself + // types this use with a unit arm (an optional property read, an + // optional parameter, a `T | undefined` local), the read needs no + // narrowing aid — the union value flows into the surrounding + // logical/conditional operator, which handles the absent arm + // itself (`(entries ?? []).map(...)`, `x?.y ?? z`). Only a hidden + // absence the checker does not admit needs the refusal below. + const checked = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if ( + checked?.kind === "union" && + local.type.kind === "union" && + lowerer.armTag(checked.unionId, UNDEFINED_T) >= 0 && + lowerer.armTag(local.type.unionId, UNDEFINED_T) >= 0 + ) + return { kind: "varRef", localId: local.id, type: local.type, loc }; lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index fcdce59f0..955b57610 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4036,8 +4036,7 @@ function lowerDynReceiverMethodCall( if ( (checkerUntyped || (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && - !call.questionDotToken && - !access.questionDotToken && + !lowerer.chainBlocked(call, access) && !call.arguments.nodes.some(ts.isSpreadElement) ) return lowerStringOrMemberCall(lowerer, call, access, recv); @@ -4053,8 +4052,6 @@ function lowerDynReceiverMethodCall( // runtime dispatch. if ( !ownCallable && - !call.questionDotToken && - !access.questionDotToken && isDynPrimitiveKindMethod(access.name.text) && !lowerer.chainBlocked(access, call) ) { @@ -4202,9 +4199,13 @@ function lowerDynReceiverMethodCall( !stringOnlyName ) return null; - // Optional forms (`obj.cb?.()`, `obj?.cb()`) belong to the chain - // machinery's short-circuit semantics — not modeled here yet. - if (call.questionDotToken || access.questionDotToken) return null; + // An UNHANDLED `?.` step (`obj.cb?.()`, `obj?.cb()`) belongs to the chain + // machinery's short-circuit semantics. Inside the guarded body of a chain + // the active lowering has ALREADY proven the receiver present, so the + // step is a plain read-and-call there — the same "read the member, call + // it" shape below, with `dynCall` throwing Node's catchable + // "obj.cb is not a function" for a present but non-callable member. + if (lowerer.chainBlocked(call, access)) return null; const loc = locOf(call); const receiverLocal = lowerer.declareHiddenLocal("%dynCallReceiver", DYN); const receiver: IrExpr = { kind: "varRef", localId: receiverLocal.id, type: DYN, loc }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json new file mode 100644 index 000000000..5b5c4b640 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8500-optional-method-call-receivers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json new file mode 100644 index 000000000..e90405629 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8501-nullish-optional-capture-receivers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8500-optional-method-call-receivers.ts b/tests/corpus/8500-optional-method-call-receivers.ts new file mode 100644 index 000000000..ddf480641 --- /dev/null +++ b/tests/corpus/8500-optional-method-call-receivers.ts @@ -0,0 +1,79 @@ +// Optional-chained METHOD calls on structural receivers: a class seen +// through `Pick`, an interface implemented by program classes, +// and the same receivers held in optional fields. Node calls the method +// when the receiver is present and answers undefined (no call) when not. + +class CacheWarmer { + status = "idle"; + onAgentSettled(): void { + console.log("settled", this.status); + } + onModeChanged(): void { + console.log("mode", this.status); + } +} + +interface Renderer { + requestRender(): void; +} + +class MainScreen implements Renderer { + requestRender(): void { + console.log("main"); + } +} + +class AltScreen implements Renderer { + requestRender(): void { + console.log("alt"); + } +} + +function settle(w: Pick | undefined): void { + w?.onAgentSettled(); +} + +function tick(tui: Renderer | undefined): void { + tui?.requestRender(); +} + +class Session { + private cacheWarmer?: Pick; + private renderer: Renderer | undefined; + constructor(w: CacheWarmer | undefined, r: Renderer | undefined) { + this.cacheWarmer = w; + this.renderer = r; + } + settle(): void { + this.cacheWarmer?.onModeChanged(); + this.renderer?.requestRender(); + } + describe(): string { + return `${this.cacheWarmer?.status ?? "none"}/${this.renderer === undefined ? "none" : "ui"}`; + } +} + +settle(new CacheWarmer()); +settle(undefined); +tick(new MainScreen()); +tick(new AltScreen()); +tick(undefined); +new Session(new CacheWarmer(), new MainScreen()).settle(); +new Session(undefined, undefined).settle(); +console.log(new Session(new CacheWarmer(), undefined).describe()); +console.log(new Session(undefined, new AltScreen()).describe()); + +// A present but non-callable member still throws Node's catchable TypeError; +// the `?.` step only skips the call when the RECEIVER is absent. +const loose: any = { render: 42 }; +try { + loose?.render(); +} catch (e) { + console.log("call:", e instanceof TypeError, (e as Error).message); +} +const looseStep: any = { render: 42 }; +try { + looseStep.render?.(); +} catch (e) { + console.log("step:", e instanceof TypeError, (e as Error).message); +} diff --git a/tests/corpus/8501-nullish-optional-capture-receivers.ts b/tests/corpus/8501-nullish-optional-capture-receivers.ts new file mode 100644 index 000000000..e4079dc5d --- /dev/null +++ b/tests/corpus/8501-nullish-optional-capture-receivers.ts @@ -0,0 +1,28 @@ +// `(x ?? fallback).method(...)` over storage the checker types +// `T | undefined`: the default takes over exactly when the stored value is +// absent — an optional record field, an optional parameter, a callback +// parameter, and an unchecked element read. + +interface Options { + tools?: string[]; +} + +const DEFAULTS = ["read", "write"]; + +function active(options: Options): string[] { + const selected = options.tools; + return (selected ?? DEFAULTS).filter((name) => name !== "write"); +} + +function collect(...failures: Array): string[] { + return failures.flatMap((entries) => (entries ?? []).map((entry) => entry.toUpperCase())); +} + +function totals(groups: number[][] | undefined): number { + return (groups ?? []).reduce((sum, group) => sum + group.length, 0); +} + +console.log(active({ tools: ["read", "write"] }).join(",")); +console.log(active({}).join(",")); +console.log(collect(["a"], undefined, ["b"]).join(",")); +console.log(totals([[1, 2], [3]]), totals(undefined)); diff --git a/tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json b/tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json new file mode 100644 index 000000000..feabbe802 --- /dev/null +++ b/tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 4200 } diff --git a/tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json b/tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json new file mode 100644 index 000000000..8ee6a84fa --- /dev/null +++ b/tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 2850, "san": 4300 } From d414d518b38f634b0e0929464f1659cf2425067c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:24:29 -0700 Subject: [PATCH 104/193] Fold constant module specifiers in dynamic import() and import.meta.resolve Module resolution is a build-time artifact, but only a string literal counted as a static specifier: `const FS = "node:" + "fs"; import(FS)` and `import("node:" + "path")` fell into the computed-specifier refusals. pi's `packages/ai` env loader writes exactly that shape. `staticString` now folds a literal, a cast or parenthesized form of one, a `+` concatenation of those, and a `const` binding whose initializer folds to one (depth-bounded); anything only known at run time still returns null, so genuinely computed specifiers keep their refusal. `import()`, `import.meta.resolve` and `createRequire`'s `resolve` share it. A folded specifier carries no module symbol on its argument node, so the own-module test and the import target fall back to the checker's program import resolution. Corpus 8502 pins both folded forms and the builtins they name. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/module-resolution.ts | 40 ++++++++++++++--- .../src/frontend/lowering/lower-modules.ts | 15 +++++-- .../src/frontend/lowering/lower-stdlib.ts | 43 +++++++++++++------ .../8502-constant-module-specifiers.ts.json | 6 +++ .../corpus/8502-constant-module-specifiers.ts | 23 ++++++++++ .../8502-constant-module-specifiers.ts.json | 1 + 6 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json create mode 100644 tests/corpus/8502-constant-module-specifiers.ts create mode 100644 tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts index 3911188e4..4e7234a48 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts @@ -467,10 +467,40 @@ export function lowerCreateRequireCall( } } -function staticString(node: ts.Expression | undefined): string | null { - if (node === undefined) return null; +/** The statically-known text of a module specifier expression. Module + * resolution is a build-time artifact, so only a value the compiler already + * knows can name a module: a string literal (or substitution-free + * template), a parenthesized or cast form of one, a `+` concatenation of + * those, or a `const` binding whose initializer folds to one + * (`const FS = "node:" + "fs"; import(FS)`). Null when the text is only + * known at run time. */ +export function staticString( + lowerer: Lowerer, + node: ts.Expression | undefined, + depth = 0, +): string | null { + if (node === undefined || depth > 8) return null; const value = stripTypeCasts(node); - return ts.isStringLiteralLike(value) ? value.text : null; + if (ts.isStringLiteralLike(value)) return value.text; + if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = staticString(lowerer, value.left, depth + 1); + if (left === null) return null; + const right = staticString(lowerer, value.right, depth + 1); + return right === null ? null : left + right; + } + if (ts.isIdentifier(value)) { + const decl = lowerer.checker.getSymbolAtLocation(value)?.valueDeclaration; + if ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + decl.initializer !== value && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ) + return staticString(lowerer, decl.initializer, depth + 1); + } + return null; } function runtimeResolveThrow(error: RuntimeResolveError, type: IrType, loc: SrcLoc): IrExpr { @@ -503,7 +533,7 @@ export function lowerImportMetaResolveCall( 'the lowered form is import.meta.resolve("") using the containing module as its parent', ); } - const specifier = staticString(call.arguments.nodes[0]); + const specifier = staticString(lowerer, call.arguments.nodes[0]); if (specifier === null) { lowerer.noLowering( "import.meta.resolve with a runtime-computed specifier", @@ -618,7 +648,7 @@ export function lowerRequireResolveCall(lowerer: Lowerer, call: ts.CallExpressio call, ); } - const specifier = staticString(call.arguments.nodes[0]); + const specifier = staticString(lowerer, call.arguments.nodes[0]); // A computed request (or options) through a createRequire loader in // JavaScript source resolves at run time. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3b2110290..39cc6aaf6 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -4,6 +4,7 @@ import { computedGlobalReadOf, globalValuesInstall } from "./global-values.js"; import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { THREADS_MODULE } from "../threads-import.js"; import { collectIslandImport, collectIslandRequires } from "./lower-node-island.js"; +import { staticString } from "./builtins/module-resolution.js"; import { NODE_API_MODULE } from "../node-api-import.js"; import { positionTag } from "./structural-positions.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; @@ -779,9 +780,10 @@ function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.nodes.length >= 1 && node.arguments.nodes[0] !== undefined && - ts.isStringLiteralLike(node.arguments.nodes[0]) + staticString(lowerer, node.arguments.nodes[0]) !== null ) { - const spec = node.arguments.nodes[0].text; + // A constant expression names its module as surely as a literal. + const spec = staticString(lowerer, node.arguments.nodes[0]!)!; if (lowerer.externalTypes.has(spec)) { return undefined; } @@ -800,9 +802,14 @@ function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { return undefined; } const modSym = lowerer.checker.getSymbolAtLocation(node.arguments.nodes[0]); + // A folded specifier carries no module symbol on its argument (the + // text comes from a `const`, not a literal), so fall back to the + // checker's program import resolution. + const folded = resolveImport(lowerer.program, sf, spec); const ownModule = - modSym !== undefined && - modSym.declarations.some((d) => ts.isSourceFile(d) && !d.isDeclarationFile); + (modSym !== undefined && + modSym.declarations.some((d) => ts.isSourceFile(d) && !d.isDeclarationFile)) || + (folded !== null && !folded.isDeclarationFile); const builtin = canonicalBuiltinModule(spec); const res = ownModule ? ({ kind: "program-module" } as const) diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 44f2b7370..c15a291ca 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2,6 +2,8 @@ import { dynUndefinedExpr, nodeThrowExpr } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; import { webStreamClassKindOf } from "./builtins/web-streams-dyn.js"; +import { staticString } from "./builtins/module-resolution.js"; +import { resolveImport } from "../program.js"; import * as ts from "../ts7/adapter.js"; import { lowerToNumberArguments } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; @@ -2229,7 +2231,11 @@ export function lowerStaticReadableStreamReaderCall( export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return null; const arg = call.arguments.nodes[0]; - if (arg === undefined || !ts.isStringLiteralLike(arg)) { + // The specifier text must be known at build time; a constant expression + // (`import("node:" + "fs")`, `import(FS)`) names its module just as a + // literal does. collectDynamicImports keys its table by the same text. + const specifier = arg === undefined ? null : staticString(lowerer, arg); + if (specifier === null) { lowerer.unsupported( "SC1090", call, @@ -2242,11 +2248,11 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression if (call.arguments.nodes.length !== 1) { lowerer.unsupported("SC1090", call, "dynamic import() with import attributes"); } - const res = lowerer.dynImports.get(`${ts.getSourceFileOfNode(call).fileName}\u0000${arg.text}`); + const res = lowerer.dynImports.get(`${ts.getSourceFileOfNode(call).fileName}\u0000${specifier}`); if (res === undefined) lowerer.refuseUnsupportedApi("'import()'", call); if (res.kind === "rejected") return lowerRejectedImport(lowerer, call, res.crash); if (res.kind === "program-module") { - return lowerOwnStaticModuleImport(lowerer, call, arg); + return lowerOwnStaticModuleImport(lowerer, call, specifier); } return lowerStaticNamespaceImport(lowerer, call, { kind: "moduleNs", @@ -2317,13 +2323,24 @@ function lowerRejectedImport( }; } -/** The checker-resolved program source behind one literal import(). */ -function programImportTarget(lowerer: Lowerer, arg: ts.StringLiteralLike): ts.SourceFile | null { - const modSym = lowerer.checker.getSymbolAtLocation(arg); - for (const d of modSym ? modSym.declarations : []) { - if (ts.isSourceFile(d) && !d.isDeclarationFile) return d; +/** The compiled module a literal specifier names. A FOLDED specifier + * (a `const` or a `+` concatenation) carries no module symbol on its + * argument node, so it resolves through the checker's program import + * resolution instead; only modules already in the program resolve. */ +function programImportTarget( + lowerer: Lowerer, + call: ts.CallExpression, + specifier: string, +): ts.SourceFile | null { + const arg = call.arguments.nodes[0]; + if (arg !== undefined && ts.isStringLiteralLike(arg)) { + const modSym = lowerer.checker.getSymbolAtLocation(arg); + for (const d of modSym ? modSym.declarations : []) { + if (ts.isSourceFile(d) && !d.isDeclarationFile) return d; + } } - return null; + const resolved = resolveImport(lowerer.program, ts.getSourceFileOfNode(call), specifier); + return resolved !== null && !resolved.isDeclarationFile ? resolved : null; } /** Static literal import of one compiled ESM module. The async helper's @@ -2334,14 +2351,14 @@ function programImportTarget(lowerer: Lowerer, arg: ts.StringLiteralLike): ts.So function lowerOwnStaticModuleImport( lowerer: Lowerer, call: ts.CallExpression, - arg: ts.StringLiteralLike, + specifier: string, ): IrExpr { - const dep = programImportTarget(lowerer, arg); + const dep = programImportTarget(lowerer, call, specifier); if (dep !== null && (dep.fileName.endsWith(".cts") || isCjsJsFile(dep, lowerer.program))) { lowerer.unsupported( "SC1090", call, - `dynamic import of the program's own CommonJS module '${arg.text}' ` + + `dynamic import of the program's own CommonJS module '${specifier}' ` + "(its namespace comes from module.exports through Node's CJS lexer, " + "which has no compiled story — require it, or import it statically)", ); @@ -2350,7 +2367,7 @@ function lowerOwnStaticModuleImport( lowerer.unsupported( "SC1090", call, - `dynamic import of the program's own module '${arg.text}' ` + + `dynamic import of the program's own module '${specifier}' ` + "(this module is not part of the compiled module graph)", ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json new file mode 100644 index 000000000..8b78bd3c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8502-constant-module-specifiers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8502-constant-module-specifiers.ts b/tests/corpus/8502-constant-module-specifiers.ts new file mode 100644 index 000000000..f3bf78c07 --- /dev/null +++ b/tests/corpus/8502-constant-module-specifiers.ts @@ -0,0 +1,23 @@ +// Module specifiers the compiler can evaluate: a `const` binding and a `+` +// concatenation of literals name their module exactly as a string literal +// does, so dynamic import() and import.meta.resolve stay build-time +// resolutions instead of computed-specifier refusals. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const FS_SPECIFIER = "node:" + "fs"; +const PATH_SPECIFIER = "node:path" as const; +const PREFIX = "node:"; +const JOIN_SPECIFIER = PREFIX + "path"; + +async function loadBuiltin(): Promise { + const fs = await import(FS_SPECIFIER); + const path = await import(JOIN_SPECIFIER); + console.log(fs.existsSync("/"), fs.existsSync("/definitely-not-here-xyz")); + console.log(path.join("a", "b") === join("a", "b"), path.basename("/a/b.ts")); +} + +await loadBuiltin(); +console.log(import.meta.resolve(PATH_SPECIFIER).startsWith("node:path")); +console.log(import.meta.resolve(FS_SPECIFIER).startsWith("node:fs")); diff --git a/tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json b/tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json new file mode 100644 index 000000000..a23eac02f --- /dev/null +++ b/tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json @@ -0,0 +1 @@ +{ "plain": 2750, "san": 4100 } From 55cbeac6c69d5c11a610bcd604e09966516f777d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:24:34 -0700 Subject: [PATCH 105/193] Pin the native-shim monkeypatching refusal boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi's compatibility shims patch host objects — `process.stdout.write = fn` to capture output, `.bind()` on a native function, `Object.defineProperty` on a module export. A compiled binary has no host object to patch: the natives are direct calls, so these sites keep an explicit refusal instead of compiling to something that silently diverges from Node. tests/diagnostics/native-shim-monkeypatching.ts pins the three shapes (SC1090 assignment to a non-variable, SC2007 on a valued overload set, SC2020 for defineProperty on an import), so the boundary is visible and any future process-object model has to update it deliberately. Co-Authored-By: Claude Opus 5.5 --- .../native-shim-monkeypatching.ts.json | 6 +++ .../diagnostics/native-shim-monkeypatching.ts | 33 ++++++++++++ .../native-shim-monkeypatching.ts.txt | 51 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json create mode 100644 tests/diagnostics/native-shim-monkeypatching.ts create mode 100644 tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json new file mode 100644 index 000000000..c1f13438c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/native-shim-monkeypatching.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/native-shim-monkeypatching.ts b/tests/diagnostics/native-shim-monkeypatching.ts new file mode 100644 index 000000000..c587bb241 --- /dev/null +++ b/tests/diagnostics/native-shim-monkeypatching.ts @@ -0,0 +1,33 @@ +// Patching a NATIVE host function: Node lets a library swap +// `process.stdout.write`, hand out `process.stdout.write.bind(...)`, or +// redefine a module member with Object.defineProperty. A compiled binary +// has no host object to patch — the natives are direct calls — so each form +// keeps an explicit refusal instead of silently patching nothing. The +// working split: call the function directly, or give the library a callback +// the program owns. + +import * as fs from "node:fs"; + +export function guardStdout(): void { + process.stdout.write = (chunk: string | Uint8Array): boolean => { + process.stderr.write(chunk); + return true; + }; +} + +export function bindStdoutWrite(): typeof process.stdout.write { + return process.stdout.write.bind(process.stdout); +} + +export function bindReadFileSync(): typeof fs.readFileSync { + return fs.readFileSync.bind(fs); +} + +export function defineReadFileSync(): void { + Object.defineProperty(fs, "readFileSync", { value: () => "" }); +} + +guardStdout(); +bindStdoutWrite(); +bindReadFileSync(); +defineReadFileSync(); diff --git a/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt new file mode 100644 index 000000000..8fb13eab8 --- /dev/null +++ b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt @@ -0,0 +1,51 @@ +native-shim-monkeypatching.ts:12:3 - error SC1090: assignment to non-variables are not supported yet + + 11 | export function guardStdout(): void { + 12 | process.stdout.write = (chunk: string | Uint8Array): boolean => { + | ^~~~~~~~~~~~~~~~~~~~ + 13 | process.stderr.write(chunk); + +native-shim-monkeypatching.ts:18:17 - error SC2007: values of type '{ (data: string | Uint8Array, callback?: ((error?: Error | null | undefined) => void) | undefined): boolean; (data: string | Uint8Array<...>, encoding: BufferEncoding, callback?: ((error?: Error | ... 1 more ... | undefined) => void) | undefined): boolean; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature + + 17 | + 18 | export function bindStdoutWrite(): typeof process.stdout.write { + | ^~~~~~~~~~~~~~~ + 19 | return process.stdout.write.bind(process.stdout); + + hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow + +native-shim-monkeypatching.ts:22:17 - error SC2007: values of type '{ (path: string | number | URL, encoding: BufferEncoding): string; (path: string | number | URL, options: { encoding: BufferEncoding; }): string; (path: string | ... 1 more ... | URL): Buffer<...>; (path: string | URL, options: unknown): string | Buffer<...>; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature + + 21 | + 22 | export function bindReadFileSync(): typeof fs.readFileSync { + | ^~~~~~~~~~~~~~~~ + 23 | return fs.readFileSync.bind(fs); + + hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow + +native-shim-monkeypatching.ts:27:3 - error SC2020: 'Object.defineProperty' is part of the standard library types but has no scriptc lowering yet + + 26 | export function defineReadFileSync(): void { + 27 | Object.defineProperty(fs, "readFileSync", { value: () => "" }); + | ^~~~~~~~~~~~~~~~~~~~~ + 28 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +native-shim-monkeypatching.ts:31:1 - error SC2004: uses of 'bindStdoutWrite' inherit the blocker on its declaration + + 30 | guardStdout(); + 31 | bindStdoutWrite(); + | ^~~~~~~~~~~~~~~ + 32 | bindReadFileSync(); + + hint: the declaration of 'bindStdoutWrite' did not compile — fix the diagnostic reported there and these sites clear with it + +native-shim-monkeypatching.ts:32:1 - error SC2004: uses of 'bindReadFileSync' inherit the blocker on its declaration + + 31 | bindStdoutWrite(); + 32 | bindReadFileSync(); + | ^~~~~~~~~~~~~~~~ + 33 | defineReadFileSync(); + + hint: the declaration of 'bindReadFileSync' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file From e77e04c0469a8791edfc977123a4846aee7b3a4a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:36:30 -0700 Subject: [PATCH 106/193] Lower process prependListener registrations, server.off, and optional child chains process.prependListener now registers at the FRONT of the signal, 'exit', and 'uncaughtException' listener lists instead of refusing: the runtime gained scr_signal_prepend and scr_process_prepend_exit (both memmove the existing entries down one slot), and the uncaught-exception registry shares one prepend implementation with the append path (scr_rej_push_at). Families with an append-only registry ('message', 'disconnect', 'unhandledRejection', 'rejectionHandled', 'warning') and prependOnceListener still refuse rather than reordering silently. Server.off/removeListener unregister the most recently added matching listener from each of the server's lists (error, close/listening, connection/secureConnection, timeout, drop) through scr_net_server_off, releasing the callback and its once-marker; the emit forms keep their existing lowering. A child handle that is a `ChildProcess | undefined` field or parameter now takes `child?.kill(...)` / `child?.on(...)`: both were gated on a questionDotToken that the optional-chain lowering has already consumed, so the chain-blocked check (lowerer.chainBlocked) replaces it. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/net.d.ts | 17 ++++++ .../ambient/scriptc-node-fallback.d.ts | 6 ++ .../compiler/src/backend/llvm/lib-process.ts | 11 +++- .../compiler/src/backend/llvm/lib-shared.ts | 6 ++ .../builtins/child-process-methods.ts | 4 +- .../src/frontend/lowering/builtins/process.ts | 59 +++++++++++++++++-- .../src/frontend/lowering/lower-server.ts | 48 +++++++++++++++ packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 17 ++++++ packages/compiler/src/ir/runtime-features.ts | 3 + .../8423-process-prepend-listeners.ts.json | 6 ++ .../corpus/8424-child-optional-chain.ts.json | 6 ++ .../tests/corpus/8425-server-off.ts.json | 6 ++ packages/runtime/src/scr_async_dyn.c | 41 +++++++++++-- packages/runtime/src/scr_events.c | 45 +++++++++++++- packages/runtime/src/scr_events.h | 2 + packages/runtime/src/scr_exception.h | 1 + packages/runtime/src/scr_net.c | 24 ++++++++ packages/runtime/src/scr_net.h | 12 ++++ .../corpus/8423-process-prepend-listeners.ts | 24 ++++++++ tests/corpus/8424-child-optional-chain.ts | 27 +++++++++ tests/corpus/8425-server-off.ts | 27 +++++++++ 22 files changed, 379 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json create mode 100644 tests/corpus/8423-process-prepend-listeners.ts create mode 100644 tests/corpus/8424-child-optional-chain.ts create mode 100644 tests/corpus/8425-server-off.ts diff --git a/packages/compiler/ambient/node-fallback/net.d.ts b/packages/compiler/ambient/node-fallback/net.d.ts index 806039fb5..3520518e7 100644 --- a/packages/compiler/ambient/node-fallback/net.d.ts +++ b/packages/compiler/ambient/node-fallback/net.d.ts @@ -257,6 +257,23 @@ declare module "net" { event: "request" | "checkContinue" | "checkExpectation", listener: (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void, ): void; + /* off / removeListener: the LAST listener standing for the same + * function leaves that event's list (Node's EventEmitter order). The + * server registries that keep a removable list: error, close, + * listening, connection/secureConnection, timeout, and drop. */ + off(event: "error", listener: (err: Error) => void): void; + off(event: "close" | "listening", listener: () => void): void; + off(event: "connection" | "secureConnection", listener: (socket: Socket) => void): void; + off(event: "timeout", listener: (socket: Socket) => void): void; + off(event: "drop", listener: (data?: DropArgument) => void): void; + removeListener(event: "error", listener: (err: Error) => void): void; + removeListener(event: "close" | "listening", listener: () => void): void; + removeListener( + event: "connection" | "secureConnection", + listener: (socket: Socket) => void, + ): void; + removeListener(event: "timeout", listener: (socket: Socket) => void): void; + removeListener(event: "drop", listener: (data?: DropArgument) => void): void; } /* The 'drop' payload: the turned-away connection's addresses. */ export interface DropArgument { diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..e406cece3 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -374,6 +374,12 @@ declare var process: { * removal see the same value. */ on(event: "SIGINT" | "SIGTERM", listener: () => void): void; on(event: "exit", listener: (code: number) => void): void; + /* prependListener — the same registration at the FRONT of the event's + * listener list (Node's EventEmitter order), for the registries that + * own a prepend entry ('exit' and the signal family). prependOnceListener + * has no lowering. */ + prependListener(event: "SIGINT" | "SIGTERM", listener: () => void): void; + prependListener(event: "exit", listener: (code: number) => void): void; /* 'warning' — Node's process-warning channel: listeners receive the * warning Error (as a dyn value); emitWarning and the runtime's * deprecation sites dispatch synchronously (SEMANTICS.md). */ diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 01efd3354..a33bf9cb9 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -679,17 +679,22 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.emitPendingCheck(); return { name: "", type: e.type }; } - if (e.fn === "process.onExit") { + if (e.fn === "process.onExit" || e.fn === "process.prependExit") { // Runtime adapters cover both shapes (the code is a plain double); // the registry owns the callback. const cbT = e.args[0]!.type; if (cbT.kind !== "func") - throw new InternalCompilerError("llvm emitter bug: process.onExit callback not a func"); + throw new InternalCompilerError(`llvm emitter bug: ${e.fn} callback not a func`); const cb = host.emitExpr(e.args[0]!); - const once = host.emitExpr(e.args[1]!); host.toHeap(cb); const adapter = cbT.params.length === 0 ? "scr_exit_thunk0" : "scr_exit_thunk_code"; host.declare(`declare void @${adapter}(ptr, double)`); + if (e.fn === "process.prependExit") { + host.declare(`declare void @scr_process_prepend_exit(ptr, ptr)`); + B.line(`call void @scr_process_prepend_exit(ptr ${cb.name}, ptr @${adapter})`); + return { name: "", type: e.type }; + } + const once = host.emitExpr(e.args[1]!); host.declare(`declare void @scr_process_on_exit(ptr, ptr, i1 zeroext)`); B.line(`call void @scr_process_on_exit(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); return { name: "", type: e.type }; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index f2646e945..cadeac305 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -838,6 +838,11 @@ export const LIB_FN_SYMS: Record = { // identity); registration is special-cased above (ownership moves). "process.offSignal": "scr_signal_off", "process.offExit": "scr_process_off_exit", + // Front-of-list registrations (process.prependListener): the signal and + // uncaught-exception registries. prependExit is special-cased below with + // the exit adapters, like onExit. + "process.prependSignal": "scr_signal_prepend", + "process.prependUncaughtException": "scr_process_prepend_uncaught_exception", // child_process (scr_child.c): spawnSync/exec forms are synchronous // (execSync/execCapture throw — the may-throw check); handle getters // are pure reads. Registration forms are special-cased (moves + per- @@ -1420,6 +1425,7 @@ export const LIB_FN_SYMS: Record = { "net.serverListening": "scr_net_server_listening", "net.sockSetRef": "scr_net_sock_set_ref", "net.serverSetRef": "scr_net_server_set_ref", + "net.serverOff": "scr_net_server_off", "net.sockWrite": "scr_net_sock_write_str", "net.sockWriteBytes": "scr_net_sock_write_bytes", "net.sockEnd": "scr_net_sock_end", diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index cdd0b73c0..c1a6ea89b 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -31,7 +31,9 @@ export function lowerChildMethodCall( call: ts.CallExpression, access: ts.PropertyAccessExpression, ): IrExpr | null { - if (call.questionDotToken || access.questionDotToken) return null; + // A `?.` in the chain re-dispatches here with the receiver bound to the + // chain (optional-chains.ts), so only an UNHANDLED token declines. + if (lowerer.chainBlocked(call, access)) return null; if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "child") return null; if (!isChildSurfaceMember(lowerer, access)) return null; const name = access.name.text; diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2eac7a17d..bd51e72e3 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -1307,13 +1307,33 @@ export function lowerProcessMethodCall( // the listener to a const so both sites see the same value), and // `removeListener` IS `off` — Node aliases them. const isOff = member === "off" || member === "removeListener"; - if (member === "on" || member === "once" || isOff) { + // process.prependListener(event, cb): Node's front-of-list registration. + // The signal, 'exit', and 'uncaughtException'/'uncaughtExceptionMonitor' + // registries have a prepend entry; every other family registers by + // appending only, so it refuses rather than reordering silently. + // prependOnceListener has no runtime entry at all. + const isPrepend = member === "prependListener"; + if (member === "prependOnceListener") { + lowerer.noLowering( + "process.prependOnceListener", + access, + "use prependListener or once (prependOnceListener has no lowering)", + ); + } + if (member === "on" || member === "once" || isOff || isPrepend) { if (call.arguments.nodes.length !== 2) { lowerer.noLowering(`process.${member} with ${call.arguments.nodes.length} arguments`, call); } const evT = lowerer.typeOf(call.arguments.nodes[0]!); const event = evT.isStringLiteralType() ? evT.value : null; if ((event === "message" || event === "disconnect") && !isOff) { + if (isPrepend) { + lowerer.noLowering( + `process.prependListener("${event}", ...)`, + call, + "prependListener is supported for signals, 'exit', and 'uncaughtException' (register these families with on/once)", + ); + } if (!resultIsDiscarded(call)) { lowerer.unsupported( "SC1090", @@ -1341,8 +1361,16 @@ export function lowerProcessMethodCall( const monitor = boolLit(event === "uncaughtExceptionMonitor", loc); return { kind: "libCall", - fn: isOff ? "process.offUncaughtException" : "process.onUncaughtException", - args: isOff ? [cb, monitor] : [cb, boolLit(member === "once", loc), monitor], + fn: isOff + ? "process.offUncaughtException" + : isPrepend + ? "process.prependUncaughtException" + : "process.onUncaughtException", + args: isOff + ? [cb, monitor] + : isPrepend + ? [cb, monitor] + : [cb, boolLit(member === "once", loc), monitor], type: VOID, loc, }; @@ -1356,6 +1384,13 @@ export function lowerProcessMethodCall( // a handler attached after delivery fires it once, synchronously // with the promise. if (event === "unhandledRejection" || event === "rejectionHandled") { + if (isPrepend) { + lowerer.noLowering( + `process.prependListener("${event}", ...)`, + call, + "prependListener is supported for signals, 'exit', and 'uncaughtException' (register these families with on/once)", + ); + } if (!resultIsDiscarded(call)) { lowerer.unsupported( "SC1090", @@ -1379,6 +1414,13 @@ export function lowerProcessMethodCall( // util.deprecate dispatch on the next tick. off/removeListener remove // by closure identity. if (event === "warning" && (member === "on" || isOff)) { + if (isPrepend) { + lowerer.noLowering( + 'process.prependListener("warning", ...)', + call, + "prependListener is supported for signals, 'exit', and 'uncaughtException' (register this family with on)", + ); + } if (!resultIsDiscarded(call)) { lowerer.unsupported( "SC1090", @@ -1413,8 +1455,12 @@ export function lowerProcessMethodCall( const cb = lowerDiagnosticsSubscriber(lowerer, call.arguments.nodes[1]!); return { kind: "libCall", - fn: isOff ? "process.offSignal" : "process.onSignal", - args: isOff ? [signal, cb] : [signal, cb, boolLit(member === "once", loc)], + fn: isOff ? "process.offSignal" : isPrepend ? "process.prependSignal" : "process.onSignal", + args: isOff + ? [signal, cb] + : isPrepend + ? [signal, cb] + : [signal, cb, boolLit(member === "once", loc)], type: VOID, loc, }; @@ -1484,6 +1530,9 @@ export function lowerProcessMethodCall( if (isOff) { return { kind: "libCall", fn: "process.offExit", args: [cb], type: VOID, loc }; } + if (isPrepend) { + return { kind: "libCall", fn: "process.prependExit", args: [cb], type: VOID, loc }; + } return { kind: "libCall", fn: "process.onExit", args: [cb, onceArg], type: VOID, loc }; } // process.emitWarning(...): the argument vector crosses as ONE dyn diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index d3132d02f..7ba3f7191 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2216,6 +2216,54 @@ function lowerNetServerMethodCall( '"connection", "request", "checkContinue", "checkExpectation", "drop", "stream", "session", "upgrade", "connect", "error", "close", "listening", and "sessionError" are the supported server events (as literals)', ); } + if ((name === "off" || name === "removeListener") && args.length === 2) { + // server.off(event, cb): the LAST listener standing for the same + // function leaves that event's list (Node's removeListener order). + // Only the event families whose registry keeps a removable list; the + // rest refuse rather than silently leaving the listener in place. + requireStatementPosition(lowerer, call, `server.${name}(...)`); + const evT = lowerer.typeOf(args[0]!); + const event = evT.isStringLiteralType() ? evT.value : null; + const kind = + event === null + ? null + : event === "error" + ? 1 + : event === "close" + ? 2 + : event === "listening" + ? 3 + : event === "connection" || event === "secureConnection" + ? 4 + : event === "timeout" + ? 5 + : event === "drop" + ? 6 + : null; + if (kind === null) { + lowerer.noLowering( + `server.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, + args[0]!, + '"error", "close", "listening", "connection", "secureConnection", "timeout", and "drop" are the events off/removeListener removes (as literals)', + ); + } + const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + const listener = lowerer.lowerExpr(args[1]!); + if (listener.type.kind !== "func") { + lowerer.unsupported( + "SC1090", + args[1]!, + `server.${name} with a listener that is not a function`, + ); + } + return { + kind: "libCall", + fn: "net.serverOff", + args: [receiver, listener, numLit(kind, loc)], + type: VOID, + loc, + }; + } if (name === "emit") { // The demux route: emit("connection", socket) hands an accepted // socket to ANOTHER server (portless's first-byte TLS peek). Only diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..53ad0b583 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -241,8 +241,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dc.tcTracePromise", "process.onUncaughtException", "process.offUncaughtException", + "process.prependUncaughtException", "process.onSignal", "process.offSignal", + "process.prependSignal", "process.onUnhandledRejection", "process.onRejectionHandled", "async.awaitDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 35f195aff..8fce72bba 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -602,6 +602,11 @@ function buildLibFnSignatures() { * alive. Invalid callbacks and uncatchable signals throw. */ "process.onSignal": { argTypes: [STRING, DYN, BOOL], result: VOID }, "process.offSignal": { argTypes: [STRING, DYN], result: VOID }, + /** process.prependListener(, cb): the same listener in front of + * the signal's existing registrations (Node appends with `on`, so the + * two differ only in dispatch order). Never `once` — that is + * prependOnceListener, which has no lowering. */ + "process.prependSignal": { argTypes: [STRING, DYN], result: VOID }, /** The process 'exit' event — process.on/once/off("exit"). args: [cb, * once bool] / [cb]. Listeners run SYNCHRONOUSLY at termination * (normal exit, process.exit(), the uncaught/unhandled exit-1 paths) @@ -610,6 +615,9 @@ function buildLibFnSignatures() { * picks the runtime adapter. Never throw. */ "process.onExit": { argTypes: [null, BOOL], result: VOID }, "process.offExit": { argTypes: [null], result: VOID }, + /** process.prependListener("exit", cb): the same listener in front of + * the exit registry's existing entries. */ + "process.prependExit": { argTypes: [null], result: VOID }, /** process.stdin listener registration — stdin.on/once("data" | "end" | * "error", cb). args: [cb, once bool]. data callbacks: () => void or * (chunk: Uint8Array) => void; end: () => void; error: () => void or @@ -1592,6 +1600,12 @@ function buildLibFnSignatures() { result: VOID, }, "net.serverOnConnection": { argTypes: [NETSERVER_T, null, BOOL], result: VOID }, + /** server.off(event, listener) / removeListener: [server, listener + * closure, list kind]. The kind selects the event's registry (1 error, + * 2 close, 3 listening, 4 connection/secureConnection, 5 timeout, + * 6 drop — the scr_net.h SCR_NET_OFF_* values); Node removes the most + * recently added matching listener. Never throws. */ + "net.serverOff": { argTypes: [NETSERVER_T, null, F64], result: VOID }, /** 'secureConnection' — the TLS server's deferred 'connection' list * (handshake-completion timing); on a server without deferred * connections (a plain net server) the registration never fires, @@ -5091,6 +5105,9 @@ function buildLibFnSignatures() { * non-function. */ /** Uncaught exception handlers and monitors: [callback dyn, once, monitor]. */ "process.onUncaughtException": { argTypes: [DYN, BOOL, BOOL], result: VOID }, + /** Uncaught handlers registered in FRONT of the registry's existing + * entries: [callback dyn, monitor] (never `once`). */ + "process.prependUncaughtException": { argTypes: [DYN, BOOL], result: VOID }, "process.offUncaughtException": { argTypes: [DYN, BOOL], result: VOID }, "process.onUnhandledRejection": { argTypes: [DYN, BOOL], result: VOID }, "process.offUnhandledRejection": { argTypes: [DYN], result: VOID }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..cb0207507 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -100,6 +100,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "process.offWarning", "process.onRejectionHandled", "process.onUncaughtException", + "process.prependUncaughtException", "process.onUnhandledRejection", "process.onWarning", "process.setDeprecationFlag", @@ -646,6 +647,8 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.offSignal", "process.onExit", "process.onSignal", + "process.prependExit", + "process.prependSignal", "process.stdio", "stdin.nextChunk", "stdin.onData", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json new file mode 100644 index 000000000..4b045387c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-process-prepend-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json new file mode 100644 index 000000000..ffd41c325 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8424-child-optional-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json new file mode 100644 index 000000000..42d0a2b16 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8425-server-off.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 1d8febbc3..99c17eeca 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -341,7 +341,11 @@ static bool scr_rej_check_listener(ScrDyn *fn) { return false; } -static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { +/* The registration body: `prepend` puts the listener in FRONT of the + * registry's existing entries (process.prependListener — Node's + * EventEmitter order), the plain form appends. */ +static void scr_rej_push_at(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once, + bool prepend) { if (*n == *cap) { *cap = *cap ? *cap * 2 : 4; *list = realloc(*list, *cap * sizeof **list); @@ -350,7 +354,18 @@ static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn * ScrRejState *state = calloc(1, sizeof *state); if (!state) scr_ad_oom(); state->refs = 1; - (*list)[(*n)++] = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; + ScrRejListener entry = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; + if (prepend) { + memmove(*list + 1, *list, *n * sizeof **list); + (*list)[0] = entry; + (*n)++; + } else { + (*list)[(*n)++] = entry; + } +} + +static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { + scr_rej_push_at(list, n, cap, fn, once, false); } static void scr_rej_remove(ScrRejListener *list, size_t *n, ScrDyn *fn) { @@ -467,15 +482,33 @@ static void scr_uncaught_teardown(void) { scr_uncaught_sync_hook(); } +/* Arm the registry's exit-time teardown once, whichever entry point + * registers first. */ +static void scr_uncaught_arm(void) { + static SCR_TL bool armed = false; + if (armed) return; + armed = true; + scr_atexit(scr_uncaught_teardown); +} + void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor) { if (!scr_rej_check_listener(fn)) return; - static SCR_TL bool armed = false; - if (!armed) { armed = true; scr_atexit(scr_uncaught_teardown); } + scr_uncaught_arm(); size_t k = monitor ? 1 : 0; scr_rej_push(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, once); scr_uncaught_sync_hook(); } +/* process.prependListener("uncaughtException"[Monitor], fn): the same + * listener in front of the registry's existing entries. */ +void scr_process_prepend_uncaught_exception(ScrDyn *fn, bool monitor) { + if (!scr_rej_check_listener(fn)) return; + scr_uncaught_arm(); + size_t k = monitor ? 1 : 0; + scr_rej_push_at(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, false, true); + scr_uncaught_sync_hook(); +} + void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor) { if (!scr_rej_check_listener(fn)) return; size_t k = monitor ? 1 : 0; diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 78b7bb0f1..9e0fb35c1 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -192,7 +192,10 @@ static bool scr_signal_check(ScrStr *name, ScrDyn *cb) { return true; } -void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { +/* The registration body both entry points share: `prepend` puts the + * listener in FRONT of the signal's existing ones (process.prependListener + * — Node's EventEmitter order), the plain form appends. */ +static void scr_signal_add(ScrStr *name, ScrDyn *cb, bool once, bool prepend) { if (!scr_signal_check(name, cb)) return; if (!scr_context_is_main()) return; #ifdef __wasi__ @@ -242,10 +245,26 @@ void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { reg->ls = realloc(reg->ls, reg->cap * sizeof *reg->ls); if (!reg->ls) scr_events_oom(); } - reg->ls[reg->n++] = (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; + ScrSigListener entry = + (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; + if (prepend) { + memmove(reg->ls + 1, reg->ls, reg->n * sizeof *reg->ls); + reg->ls[0] = entry; + reg->n++; + } else { + reg->ls[reg->n++] = entry; + } #endif } +void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { + scr_signal_add(name, cb, once, false); +} + +/* process.prependListener(, cb): the same listener in front of the + * signal's existing registrations. */ +void scr_signal_prepend(ScrStr *name, ScrDyn *cb) { scr_signal_add(name, cb, false, true); } + static void scr_sig_reg_drop(ScrSigReg *reg) { if (reg->sig > 0) { #ifdef _WIN32 @@ -1715,6 +1734,28 @@ void scr_process_off_exit_island(uint32_t island) { } #endif +/* process.prependListener("exit", cb): the same listener in front of the + * registry's existing entries. */ +void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { + if (scr_exit_ran) { + scr_closure_release(cb); + return; + } + if (scr_exit_n == scr_exit_cap) { + scr_exit_cap = scr_exit_cap ? scr_exit_cap * 2 : 2; + scr_exit_ls = realloc(scr_exit_ls, scr_exit_cap * sizeof *scr_exit_ls); + if (!scr_exit_ls) scr_events_oom(); + } + memmove(scr_exit_ls + 1, scr_exit_ls, scr_exit_n * sizeof *scr_exit_ls); + scr_exit_ls[0].cb = cb; + scr_exit_ls[0].fn = fn; + scr_exit_ls[0].once = false; +#ifdef SCR_NODE_ISLANDS + scr_exit_ls[0].island = 0; +#endif + scr_exit_n++; +} + void scr_process_off_exit(ScrClosure *cb /*borrowed*/) { for (size_t i = 0; i < scr_exit_n; i++) { #ifdef SCR_NODE_ISLANDS diff --git a/packages/runtime/src/scr_events.h b/packages/runtime/src/scr_events.h index a0025783e..3abbad8e8 100644 --- a/packages/runtime/src/scr_events.h +++ b/packages/runtime/src/scr_events.h @@ -35,8 +35,10 @@ typedef struct ScrBytes ScrBytes; /* full definition below (C11 repeat) */ void scr_events_install(void); void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once); +void scr_signal_prepend(ScrStr *name, ScrDyn *cb); void scr_signal_off(ScrStr *name, ScrDyn *cb); void scr_process_on_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double), bool once); +void scr_process_prepend_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double)); void scr_process_off_exit(ScrClosure *cb); void scr_run_exit_listeners(double code); void scr_exit_thunk0(ScrClosure *cb, double code); diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index 6c5f83358..cb3bea325 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -229,6 +229,7 @@ void scr_exc_print_uncaught(void); extern SCR_TL int (*scr_uncaught_exception_hook)(bool from_promise); bool scr_exc_handle_uncaught(bool from_promise); void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor); +void scr_process_prepend_uncaught_exception(ScrDyn *fn, bool monitor); void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor); /* The value Node reports for an unhandled rejection (scr_async_dyn.c): an * error-like reason itself, otherwise an UnhandledPromiseRejection Error diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 9347bdc9b..c17c8b004 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -2438,6 +2438,30 @@ void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool onc scr_net_ls_add(&s->close_ls, cb, NULL, once); } +/* server.off(event, cb) / removeListener: the LAST listener standing for + * the same function leaves the event's list — Node's EventEmitter removes + * the most recently added match, so the scan runs back to front. */ +void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kind) { + ScrNetLs *l = kind == SCR_NET_OFF_ERROR ? &s->err_ls + : kind == SCR_NET_OFF_CLOSE ? &s->close_ls + : kind == SCR_NET_OFF_LISTENING ? &s->listening_cbs + : kind == SCR_NET_OFF_CONNECTION ? &s->conn_ls + : kind == SCR_NET_OFF_TIMEOUT ? &s->timeout_ls + : kind == SCR_NET_OFF_DROP ? &s->drop_ls + : NULL; + if (l == NULL) return; + ScrClosure *root = cb->identity ? cb->identity : cb; + for (size_t left = l->n; left > 0; left--) { + size_t i = left - 1; + if (scr_net_listener_root(&l->ls[i]) != root) continue; + scr_closure_release(l->ls[i].cb); + scr_net_once_release(l->ls[i].shared_once); + memmove(&l->ls[i], &l->ls[i + 1], (l->n - i - 1) * sizeof *l->ls); + l->n--; + return; + } +} + /* server.on('listening', cb): joins the deferred 'listening' list the * listen(port, cb) callback rides (the sweep fires it once after a * successful bind — Node's next-tick emit). A registration AFTER the diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index af4b8e1b8..f22943717 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -199,6 +199,18 @@ void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool onc * cb) rides — fires once per successful bind; late registrations on an * already-listening server never fire (Node's once-per-listen emit). */ void scr_net_server_on_listening(ScrNetServer *s, ScrClosure *cb /*moves*/, bool once); +/* server.off(event, cb) / removeListener: the LAST listener standing for + * the same function leaves that event's list (Node's EventEmitter + * removal order). `kind` selects the list (SCR_NET_OFF_*). */ +enum { + SCR_NET_OFF_ERROR = 1, + SCR_NET_OFF_CLOSE = 2, + SCR_NET_OFF_LISTENING = 3, + SCR_NET_OFF_CONNECTION = 4, + SCR_NET_OFF_TIMEOUT = 5, + SCR_NET_OFF_DROP = 6, +}; +void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kind); void scr_net_server_on_connection(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrNetConnFn fn, bool once); /* 'secureConnection' — the deferred-connection list (TLS handshake * timing); released unread on a server without deferral (a plain net diff --git a/tests/corpus/8423-process-prepend-listeners.ts b/tests/corpus/8423-process-prepend-listeners.ts new file mode 100644 index 000000000..b12d4d336 --- /dev/null +++ b/tests/corpus/8423-process-prepend-listeners.ts @@ -0,0 +1,24 @@ +// process.prependListener registers the listener at the FRONT of an +// event's listener list (Node's EventEmitter order) for the registries +// that own a prepend entry: 'exit' and the signal family. on/once keep +// appending, so a prepend registered between them still dispatches first. +import { spawn } from "node:child_process"; + +process.on("exit", (code) => console.log(`exit on 1 (${code})`)); +process.prependListener("exit", (code) => console.log(`exit prepend 0 (${code})`)); +process.once("exit", (code) => console.log(`exit once 2 (${code})`)); +process.prependListener("exit", () => console.log("exit prepend last-registered, still first")); + +let ints = 0; +const onInt = () => { + ints++; + console.log("SIGINT on", ints); +}; +process.on("SIGINT", onInt); +process.prependListener("SIGINT", () => console.log("SIGINT prepend", ints)); + +const child = spawn("/bin/sh", ["-c", "sleep 0.2; kill -INT $PPID; sleep 0.2"], { stdio: "ignore" }); +child.on("exit", (code) => { + console.log("child exit", code ?? -1, "ints", ints); +}); +console.log("main done"); diff --git a/tests/corpus/8424-child-optional-chain.ts b/tests/corpus/8424-child-optional-chain.ts new file mode 100644 index 000000000..59581df88 --- /dev/null +++ b/tests/corpus/8424-child-optional-chain.ts @@ -0,0 +1,27 @@ +// The optional-chain form of the ChildProcess members: `this.child?.kill()` +// and `this.child?.on(...)` reach the same lowerings as the plain calls, so +// an absent child is a no-op and a present one keeps Node's shapes. +import { spawn, type ChildProcess } from "node:child_process"; + +class Runner { + child: ChildProcess | undefined; + constructor(child: ChildProcess | undefined) { + this.child = child; + } + kill(): void { + this.child?.kill("SIGTERM"); + console.log("kill attempted, child present:", this.child !== undefined); + } + watch(): void { + this.child?.on("exit", (code) => console.log("exit", code)); + } +} + +const withChild = new Runner(spawn("sleep", ["5"], { stdio: "ignore" })); +withChild.watch(); +withChild.kill(); + +const withoutChild = new Runner(undefined); +withoutChild.watch(); +withoutChild.kill(); +console.log("done"); diff --git a/tests/corpus/8425-server-off.ts b/tests/corpus/8425-server-off.ts new file mode 100644 index 000000000..f53a05c5a --- /dev/null +++ b/tests/corpus/8425-server-off.ts @@ -0,0 +1,27 @@ +// server.off / removeListener: the LAST registered listener for the same +// function leaves that event's list (Node's removeListener order), the +// remaining listeners still run, and removing an unregistered function is +// a no-op. The EADDRINUSE from a second listen on a bound port is the real +// event here. +import * as net from "node:net"; + +const first = net.createServer(); +first.listen(0, "127.0.0.1", () => { + const port = first.address().port; + const second = net.createServer(); + const removed = (err: Error) => console.log("removed listener ran", (err as NodeJS.ErrnoException).code); + const kept = (err: Error) => console.log("kept listener ran", (err as NodeJS.ErrnoException).code); + second.on("error", removed); + second.on("error", kept); + second.off("error", removed); + second.off("error", () => console.log("never registered")); + second.listen(port, "127.0.0.1", () => { + console.log("unexpected: second listen succeeded"); + second.close(); + }); + setTimeout(() => { + console.log("closing"); + second.close(); + first.close(() => console.log("first closed")); + }, 20); +}); From dda9f585e02e42b5478bfc1d0b3d71ffa93d519a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:37:40 -0700 Subject: [PATCH 107/193] Expose the process streams' fd and stdin's raw-mode flag process.stdin/stdout/stderr.fd answers Node's compile-time descriptor (0/1/2, on a TTY, a pipe, and a redirected file alike), and process.stdin.isRaw answers the TTY's raw-mode flag through scr_process_stdin_is_raw (1/0), or Node's undefined on a pipe-backed stdin through the module's interned `boolean | undefined` union. The declared type of isRaw is `boolean` (as @types/node has it), so the read is added to isOptionalProcessStreamProperty: without that entry maybeNarrow bridges the union to the declared arm and the runtime guard traps on the undefined a pipe actually answers. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 8 ++++ .../compiler/src/backend/llvm/lib-process.ts | 40 ++++++++++++++----- .../src/frontend/lowering/builtins/process.ts | 35 +++++++++++++--- .../lowering/expressions/narrowing.ts | 2 +- .../compiler/src/ir/builtin-signatures.ts | 4 ++ packages/compiler/src/ir/runtime-features.ts | 1 + packages/compiler/src/ir/validate.ts | 14 +++++++ .../8426-process-stream-fd-israw.ts.json | 6 +++ packages/runtime/src/scr_events.c | 9 +++++ packages/runtime/src/scr_events.h | 1 + tests/corpus/8426-process-stream-fd-israw.ts | 11 +++++ 11 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json create mode 100644 tests/corpus/8426-process-stream-fd-israw.ts diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index e406cece3..1480b4ee2 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -340,6 +340,12 @@ declare var process: { * (chunks stay bytes; decode with TextDecoder). */ stdin: { readonly isTTY: boolean; + /* The descriptor the stream is built over (Node reports 0 here and + * 1/2 on stdout/stderr, whatever the underlying file is). */ + readonly fd: 0; + /* True while a TTY stdin runs in raw mode; Node's own property is + * undefined on a non-TTY stdin (the `x || false` idiom). */ + readonly isRaw: boolean; on(event: "data", listener: (chunk: Uint8Array) => void): void; on(event: "end", listener: () => void): void; on(event: "error", listener: (err: Error) => void): void; @@ -1753,6 +1759,8 @@ declare module "tty" { /* The bytes queued behind the last write: the compiled binary's fd * 1/2 writes are synchronous, so it is always 0. */ readonly writableLength: number; + /* The descriptor behind the stream (1 for stdout, 2 for stderr). */ + readonly fd: number; getWindowSize(): [number, number]; } export function isatty(fd: number): boolean; diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index a33bf9cb9..f284febd8 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -1,7 +1,7 @@ /* Focused LLVM library-call emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag } from "../../ir/analysis.js"; -import { F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js"; +import { BOOL, F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { mockTimerSym } from "./lib-shared.js"; @@ -699,17 +699,26 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll B.line(`call void @scr_process_on_exit(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); return { name: "", type: e.type }; } - if (e.fn === "process.envGet" || e.fn === "process.columns" || e.fn === "process.rows") { - // getenv(3) / ioctl(TIOCGWINSZ): the runtime answers a +1 string or - // NULL (a width or a negative sentinel); the union construction is - // type-directed HERE — present wraps the value arm, absent yields + if ( + e.fn === "process.envGet" || + e.fn === "process.columns" || + e.fn === "process.rows" || + e.fn === "process.stdinIsRaw" + ) { + // getenv(3) / ioctl(TIOCGWINSZ) / termios: the runtime answers a +1 + // string or NULL, or a number with a negative sentinel (the raw-mode + // flag rides the same double channel, 1/0/-1); the union construction + // is type-directed HERE — present wraps the value arm, absent yields // the interned immortal undefined-arm instance. if (e.type.kind !== "union") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} result is not a union`); const def = host.unionsById.get(e.type.unionId); const undefTag = undefinedArmTag(e.type, host.unionsById); const isEnv = e.fn === "process.envGet"; - const valTag = def ? def.arms.findIndex((a) => a.kind === (isEnv ? "string" : "f64")) : -1; + const isRaw = e.fn === "process.stdinIsRaw"; + const valTag = def + ? def.arms.findIndex((a) => a.kind === (isEnv ? "string" : isRaw ? "bool" : "f64")) + : -1; if (valTag < 0 || undefTag < 0) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`); const args = e.args.map((a) => host.emitExpr(a)); @@ -724,6 +733,10 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.declare(`declare ptr @scr_env_get(ptr)`); B.line(`${raw} = call ptr @scr_env_get(ptr ${args[0]!.name})`); B.line(`${present} = icmp ne ptr ${raw}, null`); + } else if (isRaw) { + host.declare(`declare double @scr_process_stdin_is_raw()`); + B.line(`${raw} = call double @scr_process_stdin_is_raw()`); + B.line(`${present} = fcmp oge double ${raw}, ${f64Lit(0)}`); } else { const runtimeFn = e.fn === "process.rows" ? "scr_process_rows" : "scr_process_columns"; host.declare(`declare double @${runtimeFn}(double)`); @@ -732,9 +745,18 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll } B.condBr(present, lp, la); B.startBlock(lp); - B.line( - `store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: raw, type: isEnv ? STRING : F64 })}, ptr ${slot}`, - ); + if (isRaw) { + // The raw flag: 1/0 becomes the bool arm's i1. + const flag = B.tmp(); + B.line(`${flag} = fcmp one double ${raw}, ${f64Lit(0)}`); + B.line( + `store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: flag, type: BOOL })}, ptr ${slot}`, + ); + } else { + B.line( + `store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: raw, type: isEnv ? STRING : F64 })}, ptr ${slot}`, + ); + } B.br(lj); B.startBlock(la); B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`); diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index bd51e72e3..0d4268384 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -247,12 +247,13 @@ export function lowerProcStreamMethodCall( } /** Standard streams expose optional terminal properties: pipes have no - * isTTY, columns, or rows. The declarations do not prove their presence. */ + * isTTY, columns, or rows, and only a TTY stdin has a raw-mode flag. The + * declarations do not prove their presence. */ export function isOptionalProcessStreamProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, ): boolean { - if (!["isTTY", "columns", "rows"].includes(expr.name.text)) return false; + if (!["isTTY", "columns", "rows", "isRaw"].includes(expr.name.text)) return false; let recv = expr.expression; while (ts.isParenthesizedExpression(recv) || ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) recv = recv.expression; @@ -261,7 +262,7 @@ export function isOptionalProcessStreamProperty( if ( stream === "stdout" || stream === "stderr" || - (stream === "stdin" && expr.name.text === "isTTY") + (stream === "stdin" && (expr.name.text === "isTTY" || expr.name.text === "isRaw")) ) return true; } @@ -357,7 +358,9 @@ export function lowerProcessStreamProperty( member !== "isTTY" && member !== "columns" && member !== "rows" && - member !== "writableLength" + member !== "writableLength" && + member !== "fd" && + member !== "isRaw" ) return null; let recv: ts.Expression = expr.expression; @@ -449,7 +452,29 @@ export function lowerProcessStreamProperty( loc, }; } - if (stream === "stdin") return null; + if (member === "fd") { + // The process streams are built over fd 0/1/2 (Node answers those on + // a TTY, a pipe, and a redirected file alike). + return { + kind: "numLit", + value: stream === "stdin" ? 0 : stream === "stdout" ? 1 : 2, + type: F64, + loc, + }; + } + if (stream === "stdin") { + if (member !== "isRaw") return null; + // Node answers the raw-mode flag of a TTY stdin and undefined on + // anything else (a pipe-backed stdin); the runtime sentinel carries + // both (scr_process_stdin_is_raw). + return { + kind: "libCall", + fn: "process.stdinIsRaw", + args: [], + type: lowerer.withUndefinedArm(BOOL), + loc, + }; + } const want = lowerer.withUndefinedArm(F64); return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 6a6a25292..65c19c256 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -589,7 +589,7 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx if ( isJsSourceFile(ts.getSourceFileOfNode(node)) && ts.isPropertyAccessExpression(node) && - ["isTTY", "columns", "rows"].includes(node.name.text) && + ["isTTY", "columns", "rows", "isRaw"].includes(node.name.text) && lowerer.isStdlibMember(node) ) return expr; diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 8fce72bba..8821c13a1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5367,6 +5367,10 @@ function buildLibFnSignatures() { "process.columns": { argTypes: [F64], result: VOID }, /** Terminal height, with the same fd and optional-number contract. */ "process.rows": { argTypes: [F64], result: VOID }, + /** process.stdin.isRaw: the raw-mode flag of a TTY stdin as the + * module's interned `boolean | undefined` union (a pipe-backed stdin + * has no such flag — Node answers undefined there). Never throws. */ + "process.stdinIsRaw": { argTypes: [], result: VOID }, /** process.stdin.destroy(): a deliberate no-op (no stream machinery * exists to tear down; documented in SEMANTICS.md). */ "process.stdinDestroy": { argTypes: [], result: VOID }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index cb0207507..2c9f6d57f 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -650,6 +650,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.prependExit", "process.prependSignal", "process.stdio", + "process.stdinIsRaw", "stdin.nextChunk", "stdin.onData", "stdin.onEnd", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..cd4fc1e44 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4472,6 +4472,20 @@ function validateFunction( } return true; } + if (e.fn === "process.stdinIsRaw") { + // Result is the module's interned `boolean | undefined` union + // (Node's undefined on a pipe-backed stdin). + const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined; + const ok = + def && + def.arms.length === 2 && + def.arms[0]!.kind === "bool" && + def.arms[1]!.kind === "undefinedT"; + if (!ok) { + err(`libCall process.stdinIsRaw must return the 'boolean | undefined' union`, e.loc); + } + return true; + } return false; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json new file mode 100644 index 000000000..814d16007 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8426-process-stream-fd-israw.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 9e0fb35c1..a159c2fdc 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1734,6 +1734,15 @@ void scr_process_off_exit_island(uint32_t island) { } #endif +/* process.stdin.isRaw: the raw-mode flag of a TTY stdin (1/0); a + * pipe-backed stdin has no such flag and answers Node's undefined, which + * the emitter's union construction spells -1. */ +double scr_process_stdin_is_raw(void) { + if (!scr_context_is_main()) return -1; + if (!scr_process_is_tty(0)) return -1; + return scr_stdio_raw ? 1.0 : 0.0; +} + /* process.prependListener("exit", cb): the same listener in front of the * registry's existing entries. */ void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { diff --git a/packages/runtime/src/scr_events.h b/packages/runtime/src/scr_events.h index 3abbad8e8..5e99aa607 100644 --- a/packages/runtime/src/scr_events.h +++ b/packages/runtime/src/scr_events.h @@ -39,6 +39,7 @@ void scr_signal_prepend(ScrStr *name, ScrDyn *cb); void scr_signal_off(ScrStr *name, ScrDyn *cb); void scr_process_on_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double), bool once); void scr_process_prepend_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double)); +double scr_process_stdin_is_raw(void); void scr_process_off_exit(ScrClosure *cb); void scr_run_exit_listeners(double code); void scr_exit_thunk0(ScrClosure *cb, double code); diff --git a/tests/corpus/8426-process-stream-fd-israw.ts b/tests/corpus/8426-process-stream-fd-israw.ts new file mode 100644 index 000000000..073f4c48d --- /dev/null +++ b/tests/corpus/8426-process-stream-fd-israw.ts @@ -0,0 +1,11 @@ +// The process streams' descriptors and stdin's raw-mode flag: fd is the +// compile-time 0/1/2 Node reports for a TTY, a pipe, and a redirected file +// alike; isRaw is the TTY's raw-mode flag and Node's undefined when stdin +// is not a terminal (the harness's own pipe). +import { writeSync } from "node:fs"; + +console.log("stdin fd", process.stdin.fd); +console.log("stdout fd", process.stdout.fd); +console.log("stderr fd", process.stderr.fd); +console.log("stdin isRaw", process.stdin.isRaw, typeof process.stdin.isRaw); +writeSync(process.stdout.fd, "writeSync through process.stdout.fd\n"); From 19dbebda675884096a964af7862bf0e30828d84d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:38:22 -0700 Subject: [PATCH 108/193] Adopt an EventEmitter's type argument as the typing aid it is `class Sink extends EventEmitter` now compiles: the type argument of an EventEmitter base is the listener MAP, a pure typing aid (Node erases it too), so the base is the same emitter class whatever the map says. A stream base's argument IS the element layout and still refuses, as does every other generic base. The bundled fallback `events` module declares the class with an unconstrained map so interfaces typecheck against it, and its members stay the untyped `...args: any[]` surface either way. Co-Authored-By: Claude Opus 5.5 --- .../ambient/node-fallback/events.d.ts | 7 ++++- .../src/frontend/lowering/lower-classes.ts | 17 ++++++++-- .../corpus/8427-emitter-generic-base.ts.json | 6 ++++ tests/corpus/8427-emitter-generic-base.ts | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json create mode 100644 tests/corpus/8427-emitter-generic-base.ts diff --git a/packages/compiler/ambient/node-fallback/events.d.ts b/packages/compiler/ambient/node-fallback/events.d.ts index dda1cd086..ca55ca0c8 100644 --- a/packages/compiler/ambient/node-fallback/events.d.ts +++ b/packages/compiler/ambient/node-fallback/events.d.ts @@ -5,7 +5,12 @@ * checks annotated listener parameters against it (unannotated non-empty * parameter lists have no static types and fence at the registration). */ declare module "events" { - class EventEmitter { + /* Generic like @types/node's class: the type argument is the listener + * MAP, a pure typing aid with no runtime layout (Node erases it too), so + * `class Sink extends EventEmitter` adopts the same emitter base. + * The map stays unconstrained here — the members below are the untyped + * `...args: any[]` surface either way. */ + class EventEmitter> { constructor(); static defaultMaxListeners: number; /* Listeners registered under this symbol observe 'error' emits first diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..b06823299 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2106,7 +2106,14 @@ export function collectClassShapeInner( const emitterBase = lowerer.builtinEmitterInfoOf(resolved); const streamBaseNs = builtinStreamInfoOf(lowerer, resolved); if (emitterBase || streamBaseNs) { - if (t.typeArguments?.nodes) lowerer.unsupported("SC1090", t, "extending generic classes"); + // An EventEmitter's type argument is the listener MAP, a pure + // typing aid: the emitter base is one runtime class whatever the + // map says, and the map's keys never reach the layout (Node's + // own generic erases at runtime), so `extends + // EventEmitter` adopts the same base. A stream base's + // argument IS the element layout, which still has to be concrete. + if (!emitterBase && t.typeArguments?.nodes) + lowerer.unsupported("SC1090", t, "extending generic classes"); base = (emitterBase ?? streamBaseNs)!; continue; } @@ -2336,7 +2343,13 @@ export function collectClassShapeInner( base = instBase; continue; } - if (t.typeArguments?.nodes) lowerer.unsupported("SC1090", t, "extending generic classes"); + // An EventEmitter base's type argument is the listener MAP — a pure + // typing aid (the runtime base is one emitter class whatever the map + // says, and Node erases it too), so `extends EventEmitter` + // adopts the same base. Every other generic base still refuses: + // its argument can carry a layout. + if (t.typeArguments?.nodes && lowerer.builtinEmitterInfoOf(symbol) === null) + lowerer.unsupported("SC1090", t, "extending generic classes"); if (named?.extendsArray) lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json new file mode 100644 index 000000000..5ebf8b665 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8427-emitter-generic-base.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8427-emitter-generic-base.ts b/tests/corpus/8427-emitter-generic-base.ts new file mode 100644 index 000000000..7ffc03f55 --- /dev/null +++ b/tests/corpus/8427-emitter-generic-base.ts @@ -0,0 +1,31 @@ +// A class extending EventEmitter: the type argument is the +// listener MAP — a pure typing aid with no runtime layout of its own, so +// the emitted class adopts the same emitter base (Node erases it too). +// on/emit dispatch keeps Node's order, and the subclass's own members +// stay alongside the inherited ones. +import { EventEmitter } from "node:events"; + +interface BufferEvents { + data: [chunk: string]; + end: []; +} + +class StdinBuffer extends EventEmitter { + private chunks: string[] = []; + push(chunk: string): void { + this.chunks.push(chunk); + this.emit("data", chunk); + } + finish(): void { + console.log("joined", this.chunks.join("")); + this.emit("end"); + } +} + +const buffer = new StdinBuffer(); +buffer.on("data", (chunk) => console.log("data", chunk)); +buffer.on("end", () => console.log("end")); +buffer.push("a"); +buffer.push("b"); +buffer.finish(); +console.log("listeners", buffer.listenerCount("data")); From b8b4a4251a555ecff8aa782140cce416203dc43b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:38:33 -0700 Subject: [PATCH 109/193] Take spawn's detached option as a runtime boolean `spawn(cmd, args, { detached: process.platform !== "win32" })`, the portable daemon idiom, no longer refuses: a non-literal detached value lowers into scr_spawn_opts's bool slot, the same POSIX_SPAWN_SETSID flag the literal forms reach, so the child leads its own process group. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/child-process.ts | 10 +++--- .../8428-spawn-detached-runtime.ts.json | 6 ++++ tests/corpus/8428-spawn-detached-runtime.ts | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json create mode 100644 tests/corpus/8428-spawn-detached-runtime.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process.ts b/packages/compiler/src/frontend/lowering/builtins/child-process.ts index 9096316a3..b82984326 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process.ts @@ -477,11 +477,11 @@ export function lowerSpawnCall(lowerer: Lowerer, expr: ts.CallExpression, loc: S } else if (m.value.kind === ts.SyntaxKind.FalseKeyword) { detached = boolLit(false, loc); } else { - lowerer.noLowering( - "spawn with a non-literal detached option", - m.value, - "detached must be a boolean literal", - ); + // A runtime boolean — the portable daemon idiom `detached: + // process.platform !== "win32"` — reaches the same + // POSIX_SPAWN_SETSID flag through scr_spawn_opts's bool slot. + detached = lowerer.lowerExprExpecting(m.value, BOOL); + plain = false; } break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json new file mode 100644 index 000000000..5ffd8c9bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8428-spawn-detached-runtime.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8428-spawn-detached-runtime.ts b/tests/corpus/8428-spawn-detached-runtime.ts new file mode 100644 index 000000000..a16f24c3e --- /dev/null +++ b/tests/corpus/8428-spawn-detached-runtime.ts @@ -0,0 +1,36 @@ +// spawn's `detached` option as a RUNTIME boolean — the portable daemon +// idiom `detached: process.platform !== "win32"` — reaches the same +// POSIX_SPAWN_SETSID flag as the literal form: a detached child leads its +// own process group (pgid == pid), a non-detached one stays in the +// parent's group. +import { spawn } from "node:child_process"; + +const PGID = "ps -o pgid= -p $$ | tr -d ' '"; +const mode = process.platform !== "win32"; + +const lead = spawn("/bin/sh", ["-c", PGID], { detached: mode, stdio: ["ignore", "pipe", "ignore"] }); +let leadOut = ""; +if (lead.stdout) { + lead.stdout.setEncoding("utf8"); + lead.stdout.on("data", (chunk: string) => { + leadOut += chunk; + }); +} +lead.on("close", () => { + console.log("detached leads its own group:", leadOut.trim() === String(lead.pid)); + + const joined = spawn("/bin/sh", ["-c", PGID], { + detached: false, + stdio: ["ignore", "pipe", "ignore"], + }); + let joinedOut = ""; + if (joined.stdout) { + joined.stdout.setEncoding("utf8"); + joined.stdout.on("data", (chunk: string) => { + joinedOut += chunk; + }); + } + joined.on("close", () => { + console.log("non-detached stays behind:", joinedOut.trim() !== String(joined.pid)); + }); +}); From e59c117e3bf8017bd24d69a2df1b259bda01692a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:38:57 -0700 Subject: [PATCH 110/193] Record CI costs for the new process and child-process corpus programs The six new differential programs' plain and sanitized measurements, in tests/harness/corpus-costs/.json so the shard balancer weighs them without touching the shared table. Co-Authored-By: Claude Opus 5.5 --- .../harness/corpus-costs/8423-process-prepend-listeners.ts.json | 1 + tests/harness/corpus-costs/8424-child-optional-chain.ts.json | 1 + tests/harness/corpus-costs/8425-server-off.ts.json | 1 + tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json | 1 + tests/harness/corpus-costs/8427-emitter-generic-base.ts.json | 1 + tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json | 1 + 6 files changed, 6 insertions(+) create mode 100644 tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json create mode 100644 tests/harness/corpus-costs/8424-child-optional-chain.ts.json create mode 100644 tests/harness/corpus-costs/8425-server-off.ts.json create mode 100644 tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json create mode 100644 tests/harness/corpus-costs/8427-emitter-generic-base.ts.json create mode 100644 tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json diff --git a/tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json b/tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json new file mode 100644 index 000000000..f315263c8 --- /dev/null +++ b/tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json @@ -0,0 +1 @@ +{ "plain": 2850, "san": 21650 } diff --git a/tests/harness/corpus-costs/8424-child-optional-chain.ts.json b/tests/harness/corpus-costs/8424-child-optional-chain.ts.json new file mode 100644 index 000000000..0f324b400 --- /dev/null +++ b/tests/harness/corpus-costs/8424-child-optional-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 2300, "san": 4100 } diff --git a/tests/harness/corpus-costs/8425-server-off.ts.json b/tests/harness/corpus-costs/8425-server-off.ts.json new file mode 100644 index 000000000..4c1984f33 --- /dev/null +++ b/tests/harness/corpus-costs/8425-server-off.ts.json @@ -0,0 +1 @@ +{ "plain": 2350, "san": 5950 } diff --git a/tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json b/tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json new file mode 100644 index 000000000..1e5609a66 --- /dev/null +++ b/tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2600 } diff --git a/tests/harness/corpus-costs/8427-emitter-generic-base.ts.json b/tests/harness/corpus-costs/8427-emitter-generic-base.ts.json new file mode 100644 index 000000000..f8c3504c6 --- /dev/null +++ b/tests/harness/corpus-costs/8427-emitter-generic-base.ts.json @@ -0,0 +1 @@ +{ "plain": 2450, "san": 5700 } diff --git a/tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json b/tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json new file mode 100644 index 000000000..2447b70e5 --- /dev/null +++ b/tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json @@ -0,0 +1 @@ +{ "plain": 4250, "san": 2850 } From 1a3caa88eb781d64a163d012712dfb16e5c35c7b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:20:36 -0700 Subject: [PATCH 111/193] web/text singletons: URLSearchParams ToString, match reads, Array.from URLSearchParams crossing an implicit ToString (`${sp}`, "" + sp, String(sp)) had no lowering: ensureString now answers sp.toString, the serialized pair list. Record inits also accept shorthand members (`new URLSearchParams({ model })`) like the explicit `{ model: model }` spelling. `re.exec(s)?.index` keeps the optional chain: the read is the match row's index/input member (the chain lowering binds the guarded receiver first), so the questionDotToken guard no longer diverts it. Array.from(s) over a string whose binding the checker narrowed through a guard read as the narrowed union and fenced; the site type now decides, and the read is coerced to that string (code points, fresh array). Corpus 8423-8425 pin all three against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/url.ts | 21 ++++++++++++++++++- .../lowering/containers/array-construction.ts | 13 ++++++++++++ .../frontend/lowering/expressions/binary.ts | 4 ++++ .../lowering/expressions/property-access.ts | 4 +++- ...23-urlsearchparams-implicit-string.ts.json | 6 ++++++ .../8424-regex-match-index-chain.ts.json | 6 ++++++ .../8425-array-from-string-nullable.ts.json | 6 ++++++ .../8423-urlsearchparams-implicit-string.ts | 15 +++++++++++++ tests/corpus/8424-regex-match-index-chain.ts | 12 +++++++++++ .../corpus/8425-array-from-string-nullable.ts | 19 +++++++++++++++++ ...23-urlsearchparams-implicit-string.ts.json | 1 + .../8424-regex-match-index-chain.ts.json | 1 + .../8425-array-from-string-nullable.ts.json | 1 + 13 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json create mode 100644 tests/corpus/8423-urlsearchparams-implicit-string.ts create mode 100644 tests/corpus/8424-regex-match-index-chain.ts create mode 100644 tests/corpus/8425-array-from-string-nullable.ts create mode 100644 tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json create mode 100644 tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json create mode 100644 tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/url.ts b/packages/compiler/src/frontend/lowering/builtins/url.ts index 8619b4997..1c4736cd7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/url.ts +++ b/packages/compiler/src/frontend/lowering/builtins/url.ts @@ -228,11 +228,30 @@ function lowerSearchParamsInit(lowerer: Lowerer, arg: ts.Expression, loc: SrcLoc if (ts.isObjectLiteralExpression(arg)) { let acc: IrExpr = { kind: "libCall", fn: "sp.new", args: [], type: SEARCH_PARAMS_T, loc }; for (const prop of arg.properties.nodes) { + // `{ model }` appends the binding's value under its own name, like + // the `{ model: model }` spelling. + if (ts.isShorthandPropertyAssignment(prop)) { + const keyExpr: IrExpr = { + kind: "strLit", + value: (prop.name as ts.Identifier | ts.StringLiteral).text, + type: STRING, + loc: locOf(prop.name), + }; + const value = lowerer.coerceInto(prop.name, lowerer.lowerShorthandValue(prop), STRING); + acc = { + kind: "libCall", + fn: "sp.with", + args: [acc, keyExpr, value], + type: SEARCH_PARAMS_T, + loc, + }; + continue; + } if (!ts.isPropertyAssignment(prop) || prop.name === undefined) { lowerer.unsupported( "SC1090", prop, - "URLSearchParams record inits with spreads, accessors, or shorthand entries", + "URLSearchParams record inits with spreads or accessors", ); } let key: string; diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 2a3ad64c0..4d507b3a6 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1085,6 +1085,19 @@ export function lowerArrayFromCall( // and materializes holes while keeping element reference identity. if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) { const source = lowerer.lowerExpr(args[0]!); + // Array.from(s): the string iterator's code points, into a fresh + // array (scr_str_code_points already builds one, like Array.from). + // The checker can narrow the argument to a string while the binding + // keeps its nullable storage (`value.includes(x) && Array.from(value)`), + // so the site type decides and the read is coerced to that string. + if ( + args.length === 1 && + (source.type.kind === "string" || + lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind === "string") + ) { + const str = source.type.kind === "string" ? source : lowerer.coerceToExpected(source, STRING); + return strCharsCall(str, loc); + } let src = source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source; if (src.type.kind === "array") { diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..17c88c3c0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -648,6 +648,10 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr if (e.type.kind === "string") return e; if (e.type.kind === "url") return { kind: "libCall", fn: "url.href", args: [e], type: STRING, loc: e.loc }; + // String(sp) / `${new URLSearchParams(...)}`: URLSearchParams.toString, + // the serialized list (`?` stripped, pairs encoded). + if (e.type.kind === "searchParams") + return { kind: "libCall", fn: "sp.toString", args: [e], type: STRING, loc: e.loc }; // String(date) / `${date}` / "" + date: Date's ToPrimitive prefers // toString(). if (e.type.kind === "date") diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e566ed292..c6fd215ea 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -698,7 +698,9 @@ export function lowerPropertyAccessExpr( }; } } - if ((expr.name.text === "index" || expr.name.text === "input") && !expr.questionDotToken) { + // `re.exec(s)?.index` takes the same read: the chain lowering binds the + // guarded receiver (non-nullish inside the guard) before re-dispatching. + if (expr.name.text === "index" || expr.name.text === "input") { const represented = tryLowerExpression(lowerer, expr.expression); if ( represented && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json new file mode 100644 index 000000000..511710dcf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-urlsearchparams-implicit-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json new file mode 100644 index 000000000..46f66548d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8424-regex-match-index-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json new file mode 100644 index 000000000..24a32bab3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8425-array-from-string-nullable.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8423-urlsearchparams-implicit-string.ts b/tests/corpus/8423-urlsearchparams-implicit-string.ts new file mode 100644 index 000000000..da32d4906 --- /dev/null +++ b/tests/corpus/8423-urlsearchparams-implicit-string.ts @@ -0,0 +1,15 @@ +// URLSearchParams crossing an implicit ToString: a template span, string +// concatenation, String() and an explicit toString() all serialize the +// pair list in insertion order, percent-encoded exactly as Node does. The +// record init accepts shorthand members (`{ model }`) like `{ model: model }`. +const model = "m 1"; +const sp = new URLSearchParams({ model, autoload: "false", q: "a&b" }); +console.log(`${sp}`); +console.log("prefix:" + sp); +console.log(String(sp)); +console.log(sp.toString()); +console.log(sp.get("model"), sp.get("q"), sp.get("missing")); +const empty = new URLSearchParams({}); +console.log(`[${empty}]`, JSON.stringify(empty.toString())); +const pairs = [...sp]; +console.log(pairs.length, pairs[0]![0], pairs[0]![1]); diff --git a/tests/corpus/8424-regex-match-index-chain.ts b/tests/corpus/8424-regex-match-index-chain.ts new file mode 100644 index 000000000..4245b161d --- /dev/null +++ b/tests/corpus/8424-regex-match-index-chain.ts @@ -0,0 +1,12 @@ +// A match row read through an optional chain: `re.exec(s)?.index` and the +// `?.input` beside it take the same member reads as the guarded form. +const re = /b(c)/g; +const text = "abcz"; +const m = re.exec(text); +console.log(m !== null ? m.index : -1); +console.log(re.exec(text)?.index); +console.log(re.exec(text)?.input); +console.log(/(z)/.exec(text)?.index); +console.log(/q/.exec(text)?.index); +const groups = /(?b)/.exec(text)?.groups; +console.log(groups?.lead); diff --git a/tests/corpus/8425-array-from-string-nullable.ts b/tests/corpus/8425-array-from-string-nullable.ts new file mode 100644 index 000000000..210eb1b2a --- /dev/null +++ b/tests/corpus/8425-array-from-string-nullable.ts @@ -0,0 +1,19 @@ +// Array.from over a string the checker narrowed through a guard, and over +// code points (surrogate pairs stay whole), each a fresh array. +const values: (string | undefined)[] = ["abc", undefined, "a\u{1F600}b"]; +for (const value of values) { + if (value === undefined) { + console.log("skip"); + continue; + } + const chars = Array.from(value); + console.log(chars.length, chars.join("|")); +} +const maybe: string | undefined = "xy"; +if (maybe !== undefined) console.log(Array.from(maybe).length); +const plain: string = "q"; +console.log(Array.from(plain), Array.from(plain).length); +function guarded(v: string | undefined): boolean { + return v !== undefined && Array.from(v).length > 1; +} +console.log(guarded("ab"), guarded("a"), guarded(undefined)); diff --git a/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json b/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json new file mode 100644 index 000000000..846fa300d --- /dev/null +++ b/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json @@ -0,0 +1 @@ +{ "plain": 4163, "san": 7431 } diff --git a/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json b/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json new file mode 100644 index 000000000..2520fba26 --- /dev/null +++ b/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 2579, "san": 9345 } diff --git a/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json b/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json new file mode 100644 index 000000000..b7845d83e --- /dev/null +++ b/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json @@ -0,0 +1 @@ +{ "plain": 2724, "san": 5297 } From 4f7c48b4e311356c6b51181dfd8a09b84a054767 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:20:44 -0700 Subject: [PATCH 112/193] reflection: Reflect.has, and Object reflection over records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect.has had no lowering. It is [[HasProperty]] — own and inherited names (Array.prototype's, Object.prototype's, a class's compiled methods), proxy `has` traps, symbol and property-key conversion — which is exactly the `in` operator's checked-dynamic probe, so it lowers to dyn.hasKeyComputed over the target's dyn view (and the target is fenced when it has no such view). A primitive target is Node's TypeError, which the probe answers false for instead: fenced by name, never silently divergent. Object.getOwnPropertyNames/Symbols/Descriptors only accepted values that already were checked-dynamic; a record, array or class instance now boxes into its own dyn view (own fields and elements, in property order) like the function and error targets already did. Corpus 8426 pins both against Node. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/builtins/reflection.ts | 34 +++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 4 +++ .../8426-reflect-object-reflections.ts.json | 6 ++++ .../corpus/8426-reflect-object-reflections.ts | 28 +++++++++++++++ .../8426-reflect-object-reflections.ts.json | 1 + 5 files changed, 73 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json create mode 100644 tests/corpus/8426-reflect-object-reflections.ts create mode 100644 tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/reflection.ts b/packages/compiler/src/frontend/lowering/builtins/reflection.ts index ef55fa7e5..2988f9feb 100644 --- a/packages/compiler/src/frontend/lowering/builtins/reflection.ts +++ b/packages/compiler/src/frontend/lowering/builtins/reflection.ts @@ -62,6 +62,40 @@ export function lowerReflectCall( } if (call.questionDotToken) return null; const reflectMember = lowerer.stdlibGlobalMember(access, "Reflect"); + // Reflect.has(target, key) — the `in` operator's presence query over + // the target's checked-dynamic view (own and inherited names, proxy + // `has` traps included). A primitive target is Node's TypeError + // ("Reflect.has called on non-object"), which the presence query + // answers false for instead: fenced, never silently divergent. + if ( + reflectMember === "has" && + call.arguments.nodes.length === 2 && + !call.arguments.nodes.some(ts.isSpreadElement) + ) { + const loc = locOf(call); + const target = lowerer.lowerExpr(call.arguments.nodes[0]!); + const primitive = + isUnitType(target.type) || + ["bool", "f64", "string", "bigint", "symbol"].includes(target.type.kind); + if (primitive) + lowerer.noLowering( + "Reflect.has on a primitive target", + call.arguments.nodes[0]!, + "Node throws a TypeError for a primitive target; narrow to an object first", + ); + if (!lowerer.dynConvertible(target.type)) + lowerer.noLowering("Reflect.has on this target", call.arguments.nodes[0]!); + return { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [ + lowerer.coerceToExpected(target, DYN), + lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN), + ], + type: BOOL, + loc, + }; + } // Reflect.getOwnPropertyDescriptor and Reflect.defineProperty over // checked objects (execa's Promise mixin): the Object operations, with // defineProperty answering true. A non-object target throws the Object diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 955b57610..fa7afada4 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7885,6 +7885,10 @@ function lowerObjectStaticCall( isUnitType(object.type) ) object = { kind: "dynFrom", value: object, type: DYN, loc: locOf(call) }; + // A record, array or class instance reflects through its own + // checked-dynamic view (own fields and elements, in property order). + if (object.type.kind !== "dyn" && lowerer.dynConvertible(object.type)) + object = lowerer.coerceToExpected(object, DYN); if (object.type.kind === "dyn") return { kind: "libCall", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json new file mode 100644 index 000000000..4081fc5ef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8426-reflect-object-reflections.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8426-reflect-object-reflections.ts b/tests/corpus/8426-reflect-object-reflections.ts new file mode 100644 index 000000000..bae308d81 --- /dev/null +++ b/tests/corpus/8426-reflect-object-reflections.ts @@ -0,0 +1,28 @@ +// Reflect.has over the checked-dynamic view, and the Object reflection +// operations over records, arrays and class instances (own fields and +// elements in property order). Reflect.has is [[HasProperty]], so inherited +// names (Array.prototype's, Object.prototype's, a class's methods) answer +// true; a primitive target is Node's TypeError and stays a lowerer fence +// (SC2020) rather than a silent `false`. +class Point { + x = 1; + y = 2; + sum(): number { + return this.x + this.y; + } +} +const record = { a: 1, b: "two" }; +const list = [10, 20]; +const point = new Point(); +console.log(Reflect.has(record, "a"), Reflect.has(record, "z")); +console.log(Reflect.has(list, "0"), Reflect.has(list, "length"), Reflect.has(list, "9")); +console.log(Reflect.has(point, "x"), Reflect.has(point, "sum")); +const key = "b"; +console.log(Reflect.has(record, key), Reflect.has(record, key.toUpperCase())); +console.log(Reflect.has(record, "toString"), Reflect.has(record, "constructor")); +console.log(Object.getOwnPropertyNames(record).join(",")); +console.log(Object.getOwnPropertyNames(point).join(",")); +console.log(Object.getOwnPropertyNames(list).length); +const descriptors = Object.getOwnPropertyDescriptors(record); +console.log(Object.keys(descriptors).join(",")); +console.log(Object.getOwnPropertySymbols(record).length); diff --git a/tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json b/tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json new file mode 100644 index 000000000..a1a93d6be --- /dev/null +++ b/tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json @@ -0,0 +1 @@ +{ "plain": 3121, "san": 5843 } From f5c404c3ff858f423e00c4b651a351391a43bdab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:21:02 -0700 Subject: [PATCH 113/193] fs: copyFile's mode argument and writeFile's `flag` option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copyFileSync/copyFile took exactly two arguments; the third is libuv's copyfile flag bits (COPYFILE_EXCL 1, COPYFILE_FICLONE 2, FICLONE_FORCE 4 — fs.constants.* reads bake to literals). The surface row now carries the F64, and both forms lower to new flag-aware runtime entries that reuse the existing recursive-copy helper (reflink via ioctl, EXCL through open) after Node's own range check: a mode outside [0, 7] throws the RangeError "mode is out of range: >= 0 && <= 7". writeFileSync/writeFile fenced every `flag` option although "w" (the default truncate-or-create) and "wx" (O_EXCL: EEXIST when the path exists) write the bytes the lowered path already writes. Both literal flags now lower; every other flag still fences by name because it writes different bytes. The exclusive form is a separate runtime entry so the open flags stay compile-time known. Corpus 8427 pins the sync and promise forms of both against Node, including the EEXIST and range errors, the mode-plus-flag combination and the async twins. Co-Authored-By: Claude Opus 5.5 --- .../ambient/node-fallback/fs-promises.d.ts | 2 +- .../compiler/ambient/node-fallback/fs.d.ts | 14 ++- .../compiler/src/backend/llvm/lib-shared.ts | 4 + .../lowering/builtins/filesystem-calls.ts | 71 ++++++++++---- .../lowering/builtins/module-calls.ts | 36 ++++++- .../src/frontend/lowering/surfaces.ts | 25 +++-- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 21 ++++ .../8427-fs-copyfile-writefile-flags.ts.json | 6 ++ packages/runtime/src/scr_async.c | 10 ++ packages/runtime/src/scr_fs_cp.c | 19 ++++ packages/runtime/src/scr_lib.c | 97 +++++++++++++++++-- packages/runtime/src/scr_lib.h | 7 ++ .../8427-fs-copyfile-writefile-flags.ts | 63 ++++++++++++ .../8427-fs-copyfile-writefile-flags.ts.json | 1 + 15 files changed, 332 insertions(+), 46 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json create mode 100644 tests/corpus/8427-fs-copyfile-writefile-flags.ts create mode 100644 tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json diff --git a/packages/compiler/ambient/node-fallback/fs-promises.d.ts b/packages/compiler/ambient/node-fallback/fs-promises.d.ts index 6f3f2c460..e5d7827d0 100644 --- a/packages/compiler/ambient/node-fallback/fs-promises.d.ts +++ b/packages/compiler/ambient/node-fallback/fs-promises.d.ts @@ -210,7 +210,7 @@ declare module "fs/promises" { export function access(path: string | URL, mode?: number): Promise; export function truncate(path: string | URL, len?: number): Promise; export function appendFile(path: string | URL, data: string | Uint8Array): Promise; - export function copyFile(src: string | URL, dest: string | URL): Promise; + export function copyFile(src: string | URL, dest: string | URL, mode?: number): Promise; export function mkdtemp(prefix: string | URL): Promise; export function rmdir(path: string | URL): Promise; export function rename(oldPath: string | URL, newPath: string | URL): Promise; diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 534fad940..b8eb73357 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -30,8 +30,10 @@ declare module "node:fs" { export function writeFileSync( path: string | URL, data: string, - /* The options-record stance (see http.RequestOptions): flag fences - * by name at the call site; undocumented keys drop like Node. */ + /* The options-record stance (see http.RequestOptions): an unsupported + * flag fences by name at the call site; undocumented keys drop like + * Node. The lowered flags are "w" (truncate-or-create) and "wx" + * (exclusive create). */ options?: { mode?: number; encoding?: "utf8" | "utf-8"; @@ -157,9 +159,11 @@ declare module "node:fs" { export function lchownSync(path: string | URL, uid: number, gid: number): void; export function fchownSync(fd: number, uid: number, gid: number): void; export function truncateSync(path: string | URL, len?: number): void; - /* The 2-argument form only (Node's mode flags have no lowering). The - * destination is created or truncated carrying the SOURCE's mode. */ - export function copyFileSync(src: string | URL, dest: string | URL): void; + /* The destination is created or truncated carrying the SOURCE's mode. + * `mode` is libuv's copyfile flag bits: COPYFILE_EXCL (1) fails when the + * destination exists, COPYFILE_FICLONE (2) requests a reflink and falls + * back to a copy, COPYFILE_FICLONE_FORCE (4) requires one. */ + export function copyFileSync(src: string | URL, dest: string | URL, mode?: number): void; export function rename( oldPath: string, newPath: string, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index cadeac305..928a9caa0 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -444,6 +444,7 @@ export const LIB_FN_SYMS: Record = { "fs.unlinkSync": "scr_fs_unlink", "fs.chmodSync": "scr_fs_chmod", "fs.copyFileSync": "scr_fs_copyfile", + "fs.copyFileFlagsSync": "scr_fs_copyfile_flags", "fs.renameSync": "scr_fs_rename", "fs.accessSync": "scr_fs_access", // node:assert (scr_assert.c): all args borrowed; failures throw the @@ -883,6 +884,8 @@ export const LIB_FN_SYMS: Record = { "fsp.readFileBytes": "scr_fsp_read_file_bytes", "fsp.writeFile": "scr_fsp_write_file", "fsp.writeFileMode": "scr_fsp_write_file_mode", + "fsp.writeFileFlags": "scr_fsp_write_file_flags", + "fsp.copyFileFlags": "scr_fsp_copyfile_flags", "fsp.mkdir": "scr_fsp_mkdir", "fsp.mkdirMode": "scr_fsp_mkdir_mode", "fsp.mkdirRecursive": "scr_fsp_mkdir_recursive", @@ -1237,6 +1240,7 @@ export const LIB_FN_SYMS: Record = { "crypto.x509Verify": "scr_crypto_x509_verify", "crypto.x509CheckPrivateKey": "scr_crypto_x509_check_private_key", "fs.writeFileModeSync": "scr_fs_write_file_mode", + "fs.writeFileFlagsSync": "scr_fs_write_file_flags", "fs.chownSync": "scr_fs_chown", "fs.lchownSync": "scr_fs_lchown", "fs.fchownSync": "scr_fs_fchown", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 9bb9ee404..f86393e55 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -1049,8 +1049,14 @@ export function lowerFsWriteOptionsCall( const operation = promiseWriteOptions ? "fs.promises.writeFile" : "writeFileSync"; const plainFn: IrLibFn = promiseWriteOptions ? "fsp.writeFile" : "fs.writeFileSync"; const modeFn: IrLibFn = promiseWriteOptions ? "fsp.writeFileMode" : "fs.writeFileModeSync"; + const flagsFn: IrLibFn = promiseWriteOptions ? "fsp.writeFileFlags" : "fs.writeFileFlagsSync"; const resultType: IrType = promiseWriteOptions ? { kind: "promise", inner: VOID } : VOID; type WriteOptionValue = { kind: "effect" | "mode"; value: IrExpr }; + /** `flag: "w"` is the plain truncate-or-create write; `flag: "wx"` is + * open(2)'s O_EXCL (EEXIST when the path exists). Both are literals, so + * the bit is known here and carries no evaluation of its own. */ + let exclusive = false; + let sawFlag = false; // The runtime needs only `mode`, but every source option value is an // ordinary JS expression. Stage path/data and then evaluate the option // values in object-literal order before issuing the write; otherwise a @@ -1079,19 +1085,36 @@ export function lowerFsWriteOptionsCall( }); mode = varRef(modeLocal.id, modeLocal.type, loc); } - const result: IrExpr = { - kind: "libCall", - fn: mode ? modeFn : plainFn, - args: mode - ? [ + const result: IrExpr = sawFlag + ? { + kind: "libCall", + fn: flagsFn, + args: [ varRef(pathLocal.id, pathLocal.type, loc), varRef(dataLocal.id, dataLocal.type, loc), - mode, - ] - : [varRef(pathLocal.id, pathLocal.type, loc), varRef(dataLocal.id, dataLocal.type, loc)], - type: resultType, - loc, - }; + // Node's default creation mode when only the flag is given. + mode ?? { kind: "numLit", value: 0o666, type: F64, loc }, + { kind: "numLit", value: exclusive ? 1 : 0, type: F64, loc }, + ], + type: resultType, + loc, + } + : { + kind: "libCall", + fn: mode ? modeFn : plainFn, + args: mode + ? [ + varRef(pathLocal.id, pathLocal.type, loc), + varRef(dataLocal.id, dataLocal.type, loc), + mode, + ] + : [ + varRef(pathLocal.id, pathLocal.type, loc), + varRef(dataLocal.id, dataLocal.type, loc), + ], + type: resultType, + loc, + }; return { kind: "seqExpr", stmts, result, type: resultType, loc }; }; // The bare-encoding spelling — writeFileSync(p, data, "utf-8") — is @@ -1134,13 +1157,21 @@ export function lowerFsWriteOptionsCall( } optionValues.push({ kind: "effect", value: lowerer.lowerExprExpecting(m.value, STRING) }); } else if (m.name === "flag") { - // Documented, behavior-changing (open(2)'s disposition — 'a' - // IS appendFileSync), no lowering: fence by name. - lowerer.noLowering( - `${operation} with the flag option`, - p, - "the write truncates-or-creates (Node's default 'w'); other flags have no lowering", - ); + // Node's disposition flag changes whether an existing file is + // truncated: "w" is the default write, "wx" the exclusive create. + // Every other flag (append "a"/"as", synchronous "ws", ...) writes + // different bytes and keeps the fence by name. + const t = lowerer.typeOf(m.value); + if (!t.isStringLiteralType() || (t.value !== "w" && t.value !== "wx")) { + lowerer.noLowering( + `${operation} with the flag option`, + p, + 'the lowered flags are "w" (the default truncate-or-create) and "wx" (O_EXCL: EEXIST when the path exists); other flags write different bytes', + ); + } + exclusive = t.value === "wx"; + sawFlag = true; + optionValues.push({ kind: "effect", value: lowerer.lowerExprExpecting(m.value, STRING) }); } else { // The options-record stance: documented keys with no lowering // fence by name; undocumented keys drop like Node. @@ -1150,7 +1181,7 @@ export function lowerFsWriteOptionsCall( m.name, operation, FS_WRITE_FILE_DOCUMENTED_OPTIONS, - 'the supported options are { mode: , encoding: "utf8" }', + 'the supported options are { mode: , encoding: "utf8", flag: "w" | "wx" }', ); } } @@ -1159,7 +1190,7 @@ export function lowerFsWriteOptionsCall( lowerer.noLowering( `${operation} with 3 arguments`, optsNode, - 'the supported options are { mode: , encoding: "utf8" } over string data', + 'the supported options are { mode: , encoding: "utf8", flag: "w" | "wx" } over string data', ); } return finishWrite(optionValues); diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index bf5223d2f..b98e34782 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -35,7 +35,16 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { type BuiltinModuleFn } from "../surfaces.js"; import { type ParamShape } from "../call-signatures.js"; -import { BYTES_U8, DYN, F64, type IrExpr, STRING, type SrcLoc, arrayOf } from "../../../ir/ir.js"; +import { + BYTES_U8, + DYN, + F64, + type IrExpr, + type IrLibFn, + STRING, + type SrcLoc, + arrayOf, +} from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import { lowerProcessLoadEnvFile } from "./environment.js"; import { lowerFsWatchCall, lowerFsReaddirTypesCall, lowerFsStatOptionsCall } from "./filesystem.js"; @@ -323,6 +332,31 @@ export function lowerBuiltinModuleCall( : { kind: "numLit", value: 0, type: F64, loc }; return { kind: "libCall", fn: fn.fn, args: [path, second], type: fn.result, loc }; } + // copyFileSync(src, dest, mode?) and copyFile(src, dest, mode?): the + // mode is libuv's copyfile flag bits (COPYFILE_EXCL = 1, COPYFILE_FICLONE + // = 2, COPYFILE_FICLONE_FORCE = 4 — fs.constants.* reads bake to + // literals). The omitted form is the plain copy and keeps its libCall. + if ( + (bi.module === "fs" && bi.member === "copyFileSync") || + (bi.module === "fs/promises" && bi.member === "copyFile") + ) { + const count = expr.arguments.nodes.length; + if (count < 2 || count > 3 || expr.arguments.nodes.some(ts.isSpreadElement)) { + lowerer.noLowering(`${bi.member} with ${count} arguments`, expr); + } + const src = lowerFsPath(lowerer, expr.arguments.nodes[0]!); + const dest = lowerFsPath(lowerer, expr.arguments.nodes[1]!); + const modeNode = expr.arguments.nodes[2]; + if (!modeNode) return { kind: "libCall", fn: fn.fn, args: [src, dest], type: fn.result, loc }; + const flags: IrLibFn = bi.module === "fs" ? "fs.copyFileFlagsSync" : "fsp.copyFileFlags"; + return { + kind: "libCall", + fn: flags, + args: [src, dest, lowerer.lowerExprExpecting(modeNode, F64)], + type: fn.result, + loc, + }; + } if ( bi.module === "fs/promises" && bi.member === "appendFile" && diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..eb09c4291 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1202,9 +1202,9 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { // truncateSync's omitted len completes to 0 in lowerBuiltinModuleCall, // like accessSync's mode. truncateSync: { fn: "fs.truncateSync", params: [STRING, F64], result: VOID }, - // The 2-argument form only: Node's mode flags (COPYFILE_EXCL, ...) - // land on the arity fence. - copyFileSync: { fn: "fs.copyFileSync", params: [STRING, STRING], result: VOID }, + // The mode is libuv's copyfile flag bits (COPYFILE_EXCL, ...); the + // 2- and 3-argument forms lower in lowerBuiltinModuleCall. + copyFileSync: { fn: "fs.copyFileSync", params: [STRING, STRING, F64], result: VOID }, // The callback form is special-cased in lowerBuiltinModuleCall: its // Error | null parameter is program-shaped and needs an emitted // adapter. The row still projects the static surface and routes the @@ -1410,10 +1410,10 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { params: [STRING, STRING], result: { kind: "promise", inner: VOID }, }, - // The 2-argument form only, like copyFileSync. + // The 2- and 3-argument forms, like copyFileSync. copyFile: { fn: "fsp.copyFile", - params: [STRING, STRING], + params: [STRING, STRING, F64], result: { kind: "promise", inner: VOID }, }, mkdtemp: { fn: "fsp.mkdtemp", params: [STRING], result: { kind: "promise", inner: STRING } }, @@ -2191,15 +2191,20 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< ], // The string source's check before a URL destination converts // (fs-paths.ts). - copyFileSync: ["fs.checkPath"], + copyFileSync: ["fs.checkPath", "fs.copyFileFlagsSync"], renameSync: ["fs.checkPath"], - copyFile: ["fs.checkPath"], + copyFile: ["fs.checkPath", "fsp.copyFileFlags"], rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. // The bytes-data form, the { mode } options form, and the // file-descriptor data form. - writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"], + writeFileSync: [ + "fs.writeFileSyncBytes", + "fs.writeFileModeSync", + "fs.writeFileFlagsSync", + "fs.writeFileSyncFd", + ], appendFileSync: ["fs.appendFileSyncBytes"], // The fs stream members (bytesRead/bytesWritten, pending, path, close). createReadStream: [ @@ -2250,8 +2255,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< // The Buffer form (no encoding) and the runtime-encoding forms. readFile: ["fsp.readFileBytes", "fsp.readFileEncoded"], readlink: ["fsp.readlinkStr", "fsp.readlinkBuffer"], - // The string-data { mode } options form. - writeFile: ["fsp.writeFileMode"], + // The string-data { mode, flag } options forms. + writeFile: ["fsp.writeFileMode", "fsp.writeFileFlags"], // The { withFileTypes: true } Dirent form. readdir: ["fsp.readdirTypes"], glob: ["fsp.globTypes"], diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 53ad0b583..8f8fa9be1 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -1057,6 +1057,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dir.readSync", "dir.closeSync", "fs.copyFileSync", + "fs.copyFileFlagsSync", "fs.renameSync", "fs.renameCb", "fs.lstatSync", @@ -1079,6 +1080,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.readlinkSyncStr", "fs.readlinkSyncBuffer", "fs.writeFileModeSync", + "fs.writeFileFlagsSync", "fs.mkdirModeSync", "fs.mkdirRecursiveModeSync", "fs.openSync", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 8821c13a1..4b64f12e1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1418,6 +1418,12 @@ function buildLibFnSignatures() { * clamped like ftruncateSync's) and closes it. */ "fs.truncateSync": { argTypes: [STRING, F64], result: VOID }, "fs.copyFileSync": { argTypes: [STRING, STRING], result: VOID }, + /** copyFileSync(src, dest, mode): libuv's uv_fs_copyfile flag bits — + * COPYFILE_EXCL (1) fails on an existing destination, COPYFILE_FICLONE + * (2) requests a reflink and falls back to a copy, COPYFILE_FICLONE_FORCE + * (4) requires one. Out-of-range modes throw the RangeError Node throws + * ("mode is out of range: >= 0 && <= 7"). */ + "fs.copyFileFlagsSync": { argTypes: [STRING, STRING, F64], result: VOID }, /** renameSync is the direct two-path syscall wrapper. renameCb defers * the operation to the loop and fires a program-shaped Error | null * callback through a backend-emitted adapter. */ @@ -1425,6 +1431,10 @@ function buildLibFnSignatures() { // Callback type is program-dependent (zero params or Error | null). "fs.renameCb": { argTypes: [STRING, STRING, null], result: VOID }, "fs.writeFileModeSync": { argTypes: [STRING, STRING, F64], result: VOID }, + /** writeFileSync(path, data, { mode, flag }): the same open(2) write + * with the disposition flag — flag 1 is "wx" (O_EXCL: EEXIST when the + * path exists), 0 is "w" (the default truncate-or-create). */ + "fs.writeFileFlagsSync": { argTypes: [STRING, STRING, F64, F64], result: VOID }, "fs.mkdirModeSync": { argTypes: [STRING, F64], result: VOID }, "fs.mkdirRecursiveModeSync": { argTypes: [STRING, F64], result: VOID }, "atomics.wait": { argTypes: [bytesOf("i32"), F64, F64, F64], result: STRING }, @@ -3454,6 +3464,17 @@ function buildLibFnSignatures() { argTypes: [STRING, STRING, F64], result: { kind: "promise", inner: VOID }, }, + /** fs.promises.writeFile(path, data, { mode, flag }) and + * fs.promises.copyFile(src, dest, mode): the settled-promise twins of + * fs.writeFileFlagsSync and fs.copyFileFlagsSync. */ + "fsp.writeFileFlags": { + argTypes: [STRING, STRING, F64, F64], + result: { kind: "promise", inner: VOID }, + }, + "fsp.copyFileFlags": { + argTypes: [STRING, STRING, F64], + result: { kind: "promise", inner: VOID }, + }, "fsp.mkdir": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, /** The fs/promises option/member tail the certs pipeline uses: mkdir's * literal { recursive?, mode? } options (the mkdirSync matrix behind diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json new file mode 100644 index 000000000..3c06d6664 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8427-fs-copyfile-writefile-flags.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0225269f4..f26d520f9 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3234,6 +3234,11 @@ ScrPromise *scr_fsp_write_file_mode(ScrStr *path, ScrStr *data, double mode) { return scr_promise_settled_void(); } +ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + scr_fs_write_file_flags(path, data, mode, exclusive); + return scr_promise_settled_void(); +} + ScrPromise *scr_fsp_mkdir(ScrStr *path) { scr_fs_mkdir(path); return scr_promise_settled_void(); @@ -3294,6 +3299,11 @@ ScrPromise *scr_fsp_copyfile(ScrStr *src, ScrStr *dest) { return scr_promise_settled_void(); } +ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + scr_fs_copyfile_flags(src, dest, mode); + return scr_promise_settled_void(); +} + /* readFile(path, encoding | { encoding }): the encoding is checked first, * so an unknown name rejects before the read. */ ScrPromise *scr_fsp_read_file_encoded(ScrStr *path, ScrStr *encoding) { diff --git a/packages/runtime/src/scr_fs_cp.c b/packages/runtime/src/scr_fs_cp.c index 8a9ebec8b..f6c1fd13b 100644 --- a/packages/runtime/src/scr_fs_cp.c +++ b/packages/runtime/src/scr_fs_cp.c @@ -589,6 +589,25 @@ static void cp_copyfile(const ScrStr *src, const ScrStr *dest, int mode, const c if (e) scr_fs_throw2(e, syscall, src, dest); } +/* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's + * uv_fs_copyfile with Node's flag bits (the C++ binding's range check and + * message: any value in [0, 7], truncated to an integer — 1.5 is EXCL). + * COPYFILE_EXCL (1) fails EEXIST on an existing destination, + * COPYFILE_FICLONE (2) requests a reflink and falls back to a copy, and + * COPYFILE_FICLONE_FORCE (4) fails where no reflink is possible. This file + * excludes _WIN32, whose arm (CopyFileW and its ENOSYS for FICLONE_FORCE) + * lives in scr_lib.c beside the Win32 fs helpers. */ +void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + if (!(mode >= 0 && mode <= 7)) { + static const char text[] = "mode is out of range: >= 0 && <= 7"; + scr_throw_error_msg_code(SCR_ERR_RANGE, text, sizeof text - 1, "ERR_OUT_OF_RANGE"); + return; + } + if (!scr_fs_path_ok(src, "src")) return; + if (!scr_fs_path_ok(dest, "dest")) return; + cp_copyfile(src, dest, (int)mode, "copyfile"); +} + /* utimes(dest, atime, mtime) in seconds, through the utimesSync path. */ static bool cp_utime(const ScrStr *dest, double atime, double mtime) { scr_fs_utimes((ScrStr *)dest, atime, mtime, false); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..d4d84a6e8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -3531,12 +3531,9 @@ void scr_fs_write_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "wb"); } -/* writeFileSync(p, data, { mode }): the mode is open(2)'s O_CREAT - * argument — it applies at CREATION only (umask applying), and an - * existing file keeps its permissions, exactly Node (which never chmods - * here). Same error shapes as the plain form. */ -void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { - if (!scr_fs_path_ok(path, "path")) return; +/* Node's mode validation for the options-record write: an integer in + * open(2)'s mode_t range. Throws ERR_OUT_OF_RANGE and answers false. */ +static bool scr_fs_write_mode_ok(double mode) { char recv[48], msg[176]; scr_num_received(mode, recv); if (!(isfinite(mode) && trunc(mode) == mode)) { @@ -3544,18 +3541,27 @@ void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { "The value of \"mode\" is out of range. It must be an integer. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return; + return false; } if (mode < 0 || mode > 4294967295.0) { int len = snprintf(msg, sizeof msg, "The value of \"mode\" is out of range. It must be >= 0 && <= 4294967295. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return; + return false; } + return true; +} + +/* The open(2) write behind the mode/flag forms: O_TRUNC plus `extra` (only + * O_EXCL today), creation mode applying at CREATION only (umask applying) — + * an existing file keeps its permissions, exactly Node (which never chmods + * here). Same error shapes as the plain form. */ +static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int extra) { + if (!scr_fs_path_ok(path, "path")) return; /* O_BINARY: zero on POSIX; on Windows it keeps the CRT from translating * \n in these byte-exact writes (fopen's "wb" path already does). */ - int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, (mode_t)mode); + int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | extra, mode); if (fd < 0) { scr_fs_throw(errno, "open", path); return; @@ -3579,6 +3585,22 @@ void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { if (close(fd) != 0) scr_fs_throw(errno, "close", path); } +/* writeFileSync(p, data, { mode }). */ +void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { + if (!scr_fs_path_ok(path, "path")) return; + if (!scr_fs_write_mode_ok(mode)) return; + scr_fs_write_file_open(path, data, (mode_t)mode, 0); +} + +/* writeFileSync(p, data, { mode, flag }): Node's open(2) with the + * disposition flag. `exclusive` is "wx" (O_EXCL: EEXIST when the path + * exists); 0 is "w", the default truncate-or-create. */ +void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + if (!scr_fs_path_ok(path, "path")) return; + if (!scr_fs_write_mode_ok(mode)) return; + scr_fs_write_file_open(path, data, (mode_t)mode, exclusive != 0 ? O_EXCL : 0); +} + void scr_fs_append_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "ab"); } @@ -4648,6 +4670,63 @@ static int scr_fs_win_errno(DWORD error) { return EIO; } } + +/* libuv's fs__stat_impl_from_path identity comparison (volume serial and + * file index): the same-file CopyFileW failure below is a success. */ +static bool scr_fs_win_same_file(const WCHAR *a, const WCHAR *b) { + BY_HANDLE_FILE_INFORMATION first, second; + HANDLE handle = CreateFileW(a, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (handle == INVALID_HANDLE_VALUE) return false; + BOOL ok = GetFileInformationByHandle(handle, &first); + CloseHandle(handle); + if (!ok) return false; + handle = CreateFileW(b, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (handle == INVALID_HANDLE_VALUE) return false; + ok = GetFileInformationByHandle(handle, &second); + CloseHandle(handle); + if (!ok) return false; + return first.dwVolumeSerialNumber == second.dwVolumeSerialNumber && + first.nFileIndexHigh == second.nFileIndexHigh && + first.nFileIndexLow == second.nFileIndexLow; +} + +/* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's + * fs__copyfile in src/win/fs.c. CopyFileW carries only COPYFILE_EXCL (its + * bFailIfExists flag); COPYFILE_FICLONE (2) falls back to a plain copy and + * COPYFILE_FICLONE_FORCE (4) is the ENOSYS uv_fs_copyfile reports. The + * POSIX arm lives in scr_fs_cp.c with its reflink handling. */ +void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + if (!(mode >= 0 && mode <= 7)) { + static const char text[] = "mode is out of range: >= 0 && <= 7"; + scr_throw_error_msg_code(SCR_ERR_RANGE, text, sizeof text - 1, "ERR_OUT_OF_RANGE"); + return; + } + if (!scr_fs_path_ok(src, "src")) return; + if (!scr_fs_path_ok(dest, "dest")) return; + if (((int)mode & 4) != 0) { + scr_fs_throw2(ENOSYS, "copyfile", src, dest); + return; + } + WCHAR *from = scr_fs_win_wide(src); + WCHAR *to = scr_fs_win_wide(dest); + if (!from || !to) { + DWORD error = GetLastError(); + free(from); + free(to); + scr_fs_throw2(scr_fs_win_errno(error), "copyfile", src, dest); + return; + } + BOOL ok = CopyFileW(from, to, ((int)mode & 1) ? TRUE : FALSE); + int error = ok ? 0 : scr_fs_win_errno(GetLastError()); + if (!ok && error == EBUSY && scr_fs_win_same_file(from, to)) error = 0; + free(from); + free(to); + if (error != 0) scr_fs_throw2(error, "copyfile", src, dest); +} #endif int scr_fs_rename_raw(const ScrStr *oldpath, const ScrStr *newpath) { diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..28a0335f0 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -346,6 +346,9 @@ bool scr_fs_access_mode_valid(double mode); bool scr_fs_truncate_len_valid(double length); void scr_fs_truncate(ScrStr *path, double length); void scr_fs_copyfile(ScrStr *src, ScrStr *dest); +/* copyFileSync(src, dest, mode): libuv's copyfile flag bits (EXCL 1, + * FICLONE 2, FICLONE_FORCE 4); out-of-range modes throw Node's RangeError. */ +void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode); /* The two-path form of scr_fs_throw: "ENOENT: no such file or directory, * copyfile 'src' -> 'dest'", with path and dest own properties. */ void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr *dest); @@ -367,6 +370,8 @@ void scr_fs_rename(ScrStr *oldpath, ScrStr *newpath); int scr_fs_rename_raw(const ScrStr *oldpath, const ScrStr *newpath); void scr_fs_rename_error(int error, const ScrStr *oldpath, const ScrStr *newpath); void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode); +/* writeFileSync(path, data, { mode, flag }): `exclusive` is "wx" (O_EXCL). */ +void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive); void scr_fs_mkdir_mode(ScrStr *path, double mode); void scr_fs_mkdir_recursive_mode(ScrStr *path, double mode); /* The same walk, answering the first directory created (+1) or NULL @@ -493,6 +498,7 @@ ScrDyn *scr_stats_dyn(ScrStats *s, bool bigint); ScrPromise *scr_fsp_read_file(ScrStr *path); ScrPromise *scr_fsp_write_file(ScrStr *path, ScrStr *data); ScrPromise *scr_fsp_write_file_mode(ScrStr *path, ScrStr *data, double mode); +ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive); ScrPromise *scr_fsp_mkdir(ScrStr *path); ScrPromise *scr_fsp_mkdir_mode(ScrStr *path, double mode); ScrPromise *scr_fsp_mkdir_recursive(ScrStr *path); @@ -505,6 +511,7 @@ ScrPromise *scr_fsp_access(ScrStr *path, double mode); ScrPromise *scr_fsp_append_file(ScrStr *path, ScrStr *data); ScrPromise *scr_fsp_append_file_bytes(ScrStr *path, ScrBytes *data); ScrPromise *scr_fsp_copyfile(ScrStr *src, ScrStr *dest); +ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode); ScrPromise *scr_fsp_mkdtemp(ScrStr *prefix); ScrPromise *scr_fsp_read_file_encoded(ScrStr *path, ScrStr *encoding); ScrPromise *scr_fsp_rmdir(ScrStr *path); diff --git a/tests/corpus/8427-fs-copyfile-writefile-flags.ts b/tests/corpus/8427-fs-copyfile-writefile-flags.ts new file mode 100644 index 000000000..44b7c4e9b --- /dev/null +++ b/tests/corpus/8427-fs-copyfile-writefile-flags.ts @@ -0,0 +1,63 @@ +// copyFileSync/copyFile's third argument (libuv's flag bits) and +// writeFileSync/writeFile's `flag` option ("w" and the exclusive "wx"). +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync, constants } from "node:fs"; +import { copyFile, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dir = mkdtempSync(join(tmpdir(), "scriptc-flags-")); +const src = join(dir, "src.txt"); +const dst = join(dir, "dst.txt"); +writeFileSync(src, "payload"); +copyFileSync(src, dst, constants.COPYFILE_EXCL); +console.log(readFileSync(dst, "utf8")); +try { + copyFileSync(src, dst, constants.COPYFILE_EXCL); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +copyFileSync(src, dst); +console.log(readFileSync(dst, "utf8")); +try { + copyFileSync(src, join(dir, "x.txt"), 8); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +const wx = join(dir, "wx.txt"); +writeFileSync(wx, "one", { flag: "wx" }); +console.log(readFileSync(wx, "utf8")); +try { + writeFileSync(wx, "two", { flag: "wx" }); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +writeFileSync(wx, "three", { flag: "w" }); +console.log(readFileSync(wx, "utf8")); +const modeFile = join(dir, "mode.txt"); +writeFileSync(modeFile, "m", { mode: 0o600, flag: "wx" }); +console.log(readFileSync(modeFile, "utf8")); +await copyFile(src, join(dir, "p.txt"), constants.COPYFILE_EXCL); +console.log(await readFile(join(dir, "p.txt"), "utf8")); +try { + await copyFile(src, join(dir, "p.txt"), constants.COPYFILE_EXCL); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +const pwx = join(dir, "pwx.txt"); +await writeFile(pwx, "async-one", { flag: "wx" }); +console.log(await readFile(pwx, "utf8")); +try { + await writeFile(pwx, "async-two", { flag: "wx" }); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +rmSync(dir, { recursive: true, force: true }); diff --git a/tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json b/tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json new file mode 100644 index 000000000..495c7b29b --- /dev/null +++ b/tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json @@ -0,0 +1 @@ +{ "plain": 2673, "san": 4238 } From 5c1f32f5f16516f3f090895731a8c359a7df1de1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:21:17 -0700 Subject: [PATCH 114/193] string_decoder: write/end over a checked-dynamic chunk A stream's "data" payload arrives as checked-dynamic, and decoder.write/ decoder.end fenced it (SC2020 'StringDecoder.write of 'unknown' data'). The dyn route reads the chunk as Node does: a string returns unchanged without touching the buffered state, an ArrayBufferView (Buffer, TypedArray, DataView) decodes through the existing per-encoding helpers with the packed-f64 pending state threaded through, and anything else throws ERR_INVALID_ARG_TYPE for "buf" with Node's message. The two runtime entries live in scr_bytes.c beside their static twins and share the same `StringDecoder.write`/`end` lowering, so a program that only ever passes typed chunks keeps the existing libCalls (zero-cost). Corpus 8428 pins the string pass-through, the utf8/base64 state across an interleaved string chunk, and both argument-type errors against Node. This commit also carries this wave's compatibility evidence: the fs, url and string_decoder corpus programs are recorded in static-support.json and the ledger is regenerated with pnpm node-compat --local. Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-internal.json | 27 ++++-- internal/compatibility/static-support.json | 11 ++- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../frontend/lowering/builtins/text-codecs.ts | 90 ++++++++++++++++++- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 5 ++ ...8428-string-decoder-dynamic-chunks.ts.json | 6 ++ packages/runtime/src/scr_bytes.c | 39 ++++++++ packages/runtime/src/scr_bytes.h | 5 ++ .../8428-string-decoder-dynamic-chunks.ts | 46 ++++++++++ ...8428-string-decoder-dynamic-chunks.ts.json | 1 + 11 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json create mode 100644 tests/corpus/8428-string-decoder-dynamic-chunks.ts create mode 100644 tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 00d6f8db3..b320864bc 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -44327,6 +44327,7 @@ "tests/corpus/5570-fs-cp-async.ts", "tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts", "tests/corpus/fs-native-realpath.ts", "tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", @@ -45350,7 +45351,8 @@ "evidence": "surface-manifest:node-builtin.fs.promises.copyFile", "tests": [ "tests/corpus/5542-fs-promises-parity.cjs", - "tests/corpus/5543-fs-promises-typed.ts" + "tests/corpus/5543-fs-promises-typed.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -46091,7 +46093,8 @@ "tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", - "tests/corpus/2920-fs-promises-function-values.ts" + "tests/corpus/2920-fs-promises-function-values.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -47954,7 +47957,8 @@ "tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", - "tests/corpus/2920-fs-promises-function-values.ts" + "tests/corpus/2920-fs-promises-function-values.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -49006,7 +49010,8 @@ "tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", - "tests/corpus/2920-fs-promises-function-values.ts" + "tests/corpus/2920-fs-promises-function-values.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -91296,6 +91301,7 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts", "tests/corpus/node24-string-decoder.ts" ] }, @@ -91324,7 +91330,8 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", - "tests/corpus/5882-string-decoder-runtime-encoding.ts" + "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts" ] }, "anchorSource": "exact" @@ -91352,7 +91359,8 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", - "tests/corpus/5882-string-decoder-runtime-encoding.ts" + "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts" ] }, "anchorSource": "exact" @@ -91380,7 +91388,8 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", - "tests/corpus/5882-string-decoder-runtime-encoding.ts" + "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts" ] }, "anchorSource": "exact" @@ -99682,6 +99691,7 @@ "tests/corpus/5229-url-legacy-errors.cjs", "tests/corpus/5833-url-search-params-iterators.ts", "tests/corpus/5834-url-search-params-iterators-dynamic.mjs", + "tests/corpus/8423-urlsearchparams-implicit-string.ts", "tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-errors.js", "tests/corpus/node24-file-url-options.ts", @@ -100931,7 +100941,8 @@ "status": "partial", "evidence": "surface-manifest:node-builtin.url.urlToHttpOptions", "tests": [ - "tests/corpus/1794-searchparams-url-live.ts" + "tests/corpus/1794-searchparams-url-live.ts", + "tests/corpus/8423-urlsearchparams-implicit-string.ts" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1242ad9a7..6fd5980b6 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -59,6 +59,8 @@ "node-builtin.v8.getHeapStatistics": ["tests/corpus/v8-heap-statistics.ts", "tests/corpus/v8-require-optional.cjs"], "node-builtin.events": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"], "node-builtin.fs": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts"], + "node-builtin.fs.copyFileSync": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], + "node-builtin.fs.writeFileSync": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], "node-builtin.fs.realpathSync.native": ["tests/corpus/fs-native-realpath.ts"], "node-builtin.fs.fchmodSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], "node-builtin.fs.fstatSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], @@ -71,7 +73,8 @@ "node-builtin.fs.promises.utimes": ["tests/corpus/node24-fs-timestamps-promises.ts"], "node-builtin.fs.promises.access": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], "node-builtin.fs.promises.appendFile": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], - "node-builtin.fs.promises.copyFile": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], + "node-builtin.fs.promises.copyFile": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], + "node-builtin.fs.promises.writeFile": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], "node-builtin.fs.promises.mkdtemp": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], "node-builtin.fs.promises.rmdir": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], "node-builtin.fs.promises.truncate": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], @@ -169,12 +172,12 @@ "node-builtin.querystring": ["tests/corpus/957-builtins-namespace.ts", "tests/corpus/2917-builtin-callable-widths.ts"], "node-builtin.readline": ["tests/corpus/1475-readline-closed-stdin.ts"], "node-builtin.stream": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1746-stream-for-await.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/2629-stream-consumers.ts", "tests/corpus/2861-readable-iterator-close.ts"], - "node-builtin.string_decoder": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts"], + "node-builtin.string_decoder": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/8428-string-decoder-dynamic-chunks.ts"], "node-builtin.test": ["tests/harness/node-test.test.ts"], "node-builtin.timers": ["tests/corpus/1804-timers-module.ts", "tests/corpus/2093-timers-promises.ts"], "node-builtin.tls": ["tests/corpus/2331-tls-server-options-typed.ts", "tests/corpus/2557-tls-ca-store.ts"], "node-builtin.tty": ["tests/corpus/1448-stream-columns.ts", "tests/corpus/terminal-stream-properties.ts", "tests/harness/terminal-geometry.test.ts"], - "node-builtin.url": ["tests/corpus/1794-searchparams-url-live.ts"], + "node-builtin.url": ["tests/corpus/1794-searchparams-url-live.ts", "tests/corpus/8423-urlsearchparams-implicit-string.ts"], "node-builtin.url.pathToFileURL": ["tests/corpus/path-to-file-url-options.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], "node-builtin.url.fileURLToPath": ["tests/corpus/file-url-percent-decoding.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], "node-builtin.url.fileURLToPathBuffer": ["tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js", "tests/fixtures/node-types/file-url-conversion.ts"], @@ -475,7 +478,7 @@ { "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.once"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] }, { "symbols": ["events.getEventListeners", "events.getMaxListeners", "events.listenerCount", "events.setMaxListeners", "events.defaultMaxListeners"], "status": "partial", "evidence": ["tests/corpus/5885-events-max-listener-helpers.ts", "tests/corpus/5846-events-esm-default-max.ts", "tests/corpus/5847-events-cjs-default-max.cjs"] }, { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable", "asyncLocalStorage.name", "AsyncLocalStorage.bind", "AsyncLocalStorage.snapshot"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs", "tests/corpus/5815-als-callback-context.ts", "tests/corpus/7110-als-completion-callbacks.ts", "tests/corpus/7111-als-bind-snapshot-options.ts", "tests/corpus/7112-als-bind-snapshot-dynamic.mjs", "tests/corpus/7119-als-net-event-context.mjs", "tests/corpus/7380-als-http-event-context.mjs", "tests/corpus/7383-als-stream-events.ts", "tests/corpus/7384-als-stream-events.mjs", "tests/corpus/7385-als-child-process-events.ts", "tests/corpus/7386-als-child-fork-events/main.mjs", "tests/corpus/7387-als-http2-events.mjs", "tests/corpus/7388-als-http2-compat-events.mjs", "tests/corpus/7389-als-http2-secure-events.mjs", "tests/corpus/7391-als-response-end-and-finished.mjs"] }, - { "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] }, + { "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts", "tests/corpus/8428-string-decoder-dynamic-chunks.ts"] }, { "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] }, { "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] }, { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 928a9caa0..81d50a45c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -593,6 +593,8 @@ export const LIB_FN_SYMS: Record = { // string_decoder (scr_bytes.c): stateless helpers over (enc, pending, // chunk) — never throw. "strdec.write": "scr_strdec_write", + "strdec.writeDyn": "scr_strdec_write_dyn", + "strdec.nextDyn": "scr_strdec_next_dyn", "strdec.next": "scr_strdec_next", "strdec.end": "scr_strdec_end", "strdec.encoding": "scr_strdec_encoding", diff --git a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts index 39156940e..1100523e5 100644 --- a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts +++ b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts @@ -121,6 +121,24 @@ export function lowerStringDecoderMethodCall( loc, }; } + // A chunk that crossed the checked-dynamic boundary (a stream "data" + // payload): the runtime answers Node's write — strings pass through + // unchanged, ArrayBufferViews decode, anything else throws + // ERR_INVALID_ARG_TYPE. + if (chunk.type.kind === "dyn") { + return { + kind: "call", + callee: stringDecoderHelper( + lowerer, + name === "end" ? "endDyn" : "writeDyn", + receiver.type.shapeId, + loc, + ), + args: [receiver, chunk], + type: STRING, + loc, + }; + } chunk = checkedOptionalBuiltinArm(lowerer, chunk, BYTES_U8) ?? chunk; if (chunk.type.kind === "bytes" && chunk.type.elem !== "u8") { chunk = { @@ -153,7 +171,7 @@ export function lowerStringDecoderMethodCall( * the pure strdec.* libCalls. */ function stringDecoderHelper( lowerer: Lowerer, - op: "write" | "end" | "endChunk" | "endString" | "writeString", + op: "write" | "end" | "endChunk" | "endString" | "writeString" | "writeDyn" | "endDyn", shapeId: string, loc: SrcLoc, ): string { @@ -245,6 +263,76 @@ function stringDecoderHelper( { kind: "return", value: varRef("s.0", STRING, loc), loc }, ]; } + if (op === "writeDyn" || op === "endDyn") { + const dynChunk = { localId: "chunk.0", name: "chunk", type: DYN }; + params.push(dynChunk); + locals.splice(1, 0, { id: "chunk.0", name: "chunk", type: DYN, mutable: false }); + const writeDyn: IrExpr = { + kind: "libCall", + fn: "strdec.writeDyn", + args: [encRead(), pendingRead(), varRef("chunk.0", DYN, loc)], + type: STRING, + loc, + }; + body = [ + { kind: "varDecl", localId: "s.0", init: writeDyn, loc }, + { + kind: "recordSet", + obj: varRef("d.0", recT, loc), + shapeId, + field: "%pending", + value: { + kind: "libCall", + fn: "strdec.nextDyn", + args: [encRead(), pendingRead(), varRef("chunk.0", DYN, loc)], + type: F64, + loc, + }, + loc, + }, + { kind: "return", value: varRef("s.0", STRING, loc), loc }, + ]; + if (op === "endDyn") { + // end(buf) is write(buf) followed by the flush of whatever the write + // left buffered (Node's end). + locals.push({ id: "tail.0", name: "tail", type: STRING, mutable: false }); + body.splice( + body.length - 1, + 0, + { + kind: "varDecl", + localId: "tail.0", + init: { + kind: "libCall", + fn: "strdec.end", + args: [encRead(), pendingRead()], + type: STRING, + loc, + }, + loc, + }, + { + kind: "recordSet", + obj: varRef("d.0", recT, loc), + shapeId, + field: "%pending", + value: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, + ); + body[body.length - 1] = { + kind: "return", + value: { + kind: "strConcat", + left: varRef("s.0", STRING, loc), + right: varRef("tail.0", STRING, loc), + type: STRING, + loc, + }, + loc, + }; + } + } if (op === "endString" || op === "writeString") { const inputT = STRING; params.push({ localId: "chunk.0", name: "chunk", type: inputT }); diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 8f8fa9be1..043297e46 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -91,6 +91,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "child.onDyn", "text.decoderEncoding", "strdec.encoding", + "strdec.writeDyn", + "strdec.nextDyn", "dyn.construct", "dyn.reflectApply", "dyn.reflectGet", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 4b64f12e1..f0a5409b1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4901,6 +4901,11 @@ function buildLibFnSignatures() { * packed NEW pending; end: (enc, pending) → the buffered partial's * flush (+1). Node-exact per encoding (oracle-pinned); none throws. */ "strdec.write": { argTypes: [STRING, F64, BYTES_U8], result: STRING }, + /** The checked-dynamic chunk forms (a stream "data" payload): a string + * passes through unchanged without touching the buffered state, an + * ArrayBufferView decodes, anything else throws ERR_INVALID_ARG_TYPE. */ + "strdec.writeDyn": { argTypes: [STRING, F64, DYN], result: STRING }, + "strdec.nextDyn": { argTypes: [STRING, F64, DYN], result: F64 }, "strdec.next": { argTypes: [STRING, F64, BYTES_U8], result: F64 }, "strdec.end": { argTypes: [STRING, F64], result: STRING }, /** A runtime encoding argument's canonical name (Node's normalizeEncoding: diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json new file mode 100644 index 000000000..4d1ce9721 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8428-string-decoder-dynamic-chunks.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 6bb56195a..3e7277dc7 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -1509,6 +1509,45 @@ double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk) return hold; } +/* The bytes view of a checked-dynamic write chunk, or NULL for a string + * chunk (Node's write returns it unchanged, never touching the decoder). + * Anything that is not a string or an ArrayBufferView throws Node's + * ERR_INVALID_ARG_TYPE for "buf". */ +static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { + *is_string = chunk->kind == SCR_DYN_STR; + if (*is_string) return NULL; + if ( + chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || + scr_buffer_storage_is(chunk)) { + return scr_bytes_buffer_source(chunk); + } + scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); + return NULL; +} + +/* decoder.write(chunk) where the chunk crossed the checked-dynamic + * boundary (`stream.on("data", (data) => decoder.write(data))`). */ +ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return scr_str_retain(chunk->v.str); + if (!view) return NULL; + ScrStr *out = scr_strdec_write(enc, pending, view); + scr_bytes_release(view); + return out; +} + +/* The decoder state after that write: a string chunk leaves it alone. */ +double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return pending; + if (!view) return 0; + double hold = scr_strdec_next(enc, pending, view); + scr_bytes_release(view); + return hold; +} + /* decoder.end(): the buffered partial flushes — utf8's truncated sequence * becomes its replacement chars (the maximal-subpart rule), base64's * remainder encodes (padded; unpadded for base64url), utf16le's held diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4b9174bc0..8929e1a56 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -155,6 +155,11 @@ double scr_bytes_to_u8_clamp(double value); * incomplete-tail walk, base64's mod-3 groups, utf16le's odd-byte and * trailing-lead-surrogate holds. None throws. */ ScrStr *scr_strdec_write(const ScrStr *enc, double pending, const ScrBytes *chunk); +/* The checked-dynamic chunk forms: a string passes through unchanged (and + * leaves the buffered state alone); an ArrayBufferView decodes; anything + * else throws Node's ERR_INVALID_ARG_TYPE. */ +ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk); +double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk); double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk); ScrStr *scr_strdec_end(const ScrStr *enc, double pending); ScrStr *scr_strdec_encoding(const ScrDyn *enc); /* +1, or NULL with ERR_UNKNOWN_ENCODING pending */ diff --git a/tests/corpus/8428-string-decoder-dynamic-chunks.ts b/tests/corpus/8428-string-decoder-dynamic-chunks.ts new file mode 100644 index 000000000..6f97f1079 --- /dev/null +++ b/tests/corpus/8428-string-decoder-dynamic-chunks.ts @@ -0,0 +1,46 @@ +// StringDecoder.write over a chunk that crossed the checked-dynamic +// boundary: a string passes through unchanged, an ArrayBufferView decodes, +// and anything else throws Node's ERR_INVALID_ARG_TYPE. The decoder state +// survives an interleaved string chunk. +import { StringDecoder } from "node:string_decoder"; + +// The chunk arrives untyped (a stream "data" payload), like pi's exec.ts. +function write(decoder: StringDecoder, chunk: any): string { + return decoder.write(chunk); +} + +const decoder = new StringDecoder("utf8"); +console.log(JSON.stringify(write(decoder, "plain"))); +console.log(JSON.stringify(write(decoder, Buffer.from([0x61, 0xe2])))); +console.log(JSON.stringify(write(decoder, Buffer.from([0x82, 0xac, 0x62])))); +console.log(JSON.stringify(write(decoder, "mid"))); +try { + write(decoder, 123); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +try { + write(decoder, {}); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +const ender = new StringDecoder("base64"); +console.log(JSON.stringify(write(ender, Buffer.from([0x66])))); +console.log(JSON.stringify(ender.end())); +// end(chunk) is write(chunk) followed by the flush, over the same dynamic +// chunk shapes. +function endWith(decoder: StringDecoder, chunk: any): string { + return decoder.end(chunk); +} +const tail = new StringDecoder("utf8"); +console.log(JSON.stringify(write(tail, "x"))); +console.log(JSON.stringify(endWith(tail, Buffer.from([0xe2, 0x82])))); +console.log(JSON.stringify(endWith(new StringDecoder("utf8"), "mid"))); +try { + endWith(new StringDecoder("utf8"), 123); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} diff --git a/tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json b/tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json new file mode 100644 index 000000000..5c6c747fe --- /dev/null +++ b/tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json @@ -0,0 +1 @@ +{ "plain": 2575, "san": 3939 } From 26f15896eb7ca9e5184f846d13ca60e28f1721bf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:09:44 -0700 Subject: [PATCH 115/193] reflection: keep a record's static key walk for getOwnPropertyNames Object.getOwnPropertyNames over a record lowered through the checked-dynamic boxing this wave added for arrays and class instances, which linked the dynamic tier into a fully typed record walk and changed a program's binary for no semantic gain. The static key walk is exactly Object.keys' construction, so a record keeps it; the descriptor and symbol forms have no static walk and still take the dyn view, as do arrays and class instances. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lower-calls.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index fa7afada4..fe35f8507 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7885,9 +7885,15 @@ function lowerObjectStaticCall( isUnitType(object.type) ) object = { kind: "dynFrom", value: object, type: DYN, loc: locOf(call) }; - // A record, array or class instance reflects through its own - // checked-dynamic view (own fields and elements, in property order). - if (object.type.kind !== "dyn" && lowerer.dynConvertible(object.type)) + // An array or class instance reflects through its own checked-dynamic + // view (own fields and elements, in property order). A record keeps its + // static key walk below when it has one — `getOwnPropertyNames` over a + // fixed record is exactly Object.keys' construction, and boxing a typed + // record would link the dynamic tier into a program that needs none of + // it. The descriptor and symbol forms have no static walk, so a record + // takes the view there. + const staticWalk = member === "getOwnPropertyNames" && object.type.kind === "record"; + if (!staticWalk && object.type.kind !== "dyn" && lowerer.dynConvertible(object.type)) object = lowerer.coerceToExpected(object, DYN); if (object.type.kind === "dyn") return { From 9172fecf191ccf932fcc2bb334518c6d014c445c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:09:49 -0700 Subject: [PATCH 116/193] runtime: link the wave's new entries only into the programs that use them Every new runtime entry this wave added landed in a unit that baseline programs link (scr_lib.c's write/copyFile flags, scr_bytes.c's string_decoder dyn forms, scr_events.c's prepend registries and stdin.isRaw, scr_async_dyn.c's uncaught prepend, scr_net.c's server.off). A changed unit changes its object's build-id and symbol metadata even when every retained function is byte-identical, so 1517 corpus programs reported a runtime change for code they never call. Each new entry now lives where only a program that uses it can reach it: - writeFile's `flag` option and the fs/promises twins move to scr_fs_flags.c (feature fsFlags); copyFile's mode form reuses scr_fs_cp.c's reflink walk, so it is that unit's SCR_FS_FLAGS build. - string_decoder's checked-dynamic chunk forms move to scr_bytes_strdec_dyn.c (feature strdecDyn). - the prepend registrations, stdin.isRaw and server.off are the SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and the SCR_NET_SERVER_OFF build of scr_net.c (feature processExtras). The prepend entry points reuse the append registration and move the new entry to the front, so no existing function changes and every default object stays byte-identical. The Windows copyFile arm stays beside the Win32 path helpers in scr_lib.c, inside its _WIN32 block. The source build (the -O1 -fsanitize=address lane) compiles these units from their sources rather than from the prebuilt pack, so RUNTIME_DEFINES names the same three builds the pack matrix selects. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 3 + .../src/backend/native-link-info-core.ts | 9 ++ .../compiler/src/backend/native/contracts.ts | 9 ++ .../compiler/src/backend/native/executable.ts | 12 ++ .../src/executable/executable-cache.ts | 3 + packages/compiler/src/index.ts | 3 + packages/compiler/src/ir/runtime-features.ts | 30 +++++ .../runtime-pack-matrix.mjs | 60 ++++++++++ packages/runtime/src/scr_async.c | 10 -- packages/runtime/src/scr_async_dyn.c | 49 +++------ packages/runtime/src/scr_bytes.c | 39 ------- packages/runtime/src/scr_bytes_strdec_dyn.c | 50 +++++++++ packages/runtime/src/scr_events.c | 103 +++++++++--------- packages/runtime/src/scr_fs_cp.c | 12 +- packages/runtime/src/scr_fs_flags.c | 102 +++++++++++++++++ packages/runtime/src/scr_lib.c | 46 +++----- packages/runtime/src/scr_net.c | 6 +- 17 files changed, 380 insertions(+), 166 deletions(-) create mode 100644 packages/runtime/src/scr_bytes_strdec_dyn.c create mode 100644 packages/runtime/src/scr_fs_flags.c diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..0fdc92436 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -20,6 +20,9 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.fsFlags ? { fsFlags: true } : {}), + ...(features.strdecDyn ? { strdecDyn: true } : {}), + ...(features.processExtras ? { processExtras: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..a76aea1c8 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -34,6 +34,15 @@ export interface NativeLinkFeatures { /** The program uses process.stdin.setEncoding, string 'data' listeners * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ stdioExtended?: boolean; + /** writeFile's `flag` disposition or copyFile's mode argument: + * scr_fs_flags.c and the SCR_FS_FLAGS build of scr_fs_cp.c. */ + fsFlags?: boolean; + /** string_decoder's checked-dynamic chunk forms: scr_bytes_strdec_dyn.c. */ + strdecDyn?: boolean; + /** process.prependListener, stdin.isRaw or server.off: the + * SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and the + * SCR_NET_SERVER_OFF build of scr_net.c. */ + processExtras?: boolean; /** Calls into Node.js (@scriptc/node): scr_node_island.c and the * SCR_NODE_ISLANDS variant of the event loop unit. */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..522b355b2 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -161,6 +161,15 @@ export interface CcOptions { warningReplace?: boolean; /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ stdioExtended?: boolean; + /** SCR_FS_FLAGS: writeFile's `flag` option and copyFile's mode argument + * (scr_fs_flags.c and that build of scr_fs_cp.c). */ + fsFlags?: boolean; + /** string_decoder's checked-dynamic chunk forms: compiles + * scr_bytes_strdec_dyn.c. */ + strdecDyn?: boolean; + /** SCR_PROCESS_EXTRAS / SCR_NET_SERVER_OFF: process.prependListener, + * stdin.isRaw and server.off. */ + processExtras?: boolean; /** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and * the event loop with SCR_NODE_ISLANDS (its helper slot). */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 01efc7e34..8d8979185 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -153,6 +153,11 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ), unit((u) => u.opts.copying, "scr_copying.c"), unit((u) => u.opts.fileHandle, "scr_file_handle.c"), + // writeFile's `flag` option and the fs/promises twins; the copyFile mode + // argument is the SCR_FS_FLAGS build of scr_fs_cp.c. + unit((u) => u.opts.fsFlags === true, "scr_fs_flags.c"), + // string_decoder's checked-dynamic chunk forms, beside their typed twins. + unit((u) => u.opts.strdecDyn === true, "scr_bytes_strdec_dyn.c"), // win32 targets compile the libc-shim TU (stpcpy, arc4random_buf, // gmtime_r, strcasestr — the _WIN32 block in scr_runtime.h declares // them) and link advapi32 (the CSPRNG RtlGenRandom/SystemFunction036, @@ -412,6 +417,13 @@ const RUNTIME_DEFINES: readonly { { define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true }, { define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace }, { define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended }, + // The wave's prepend registrations and stdin.isRaw (scr_events.c, + // scr_async_dyn.c), server.off (scr_net.c) and copyFile's mode form + // (scr_fs_cp.c). The source build compiles these units from source, so the + // defines must name the same builds runtime-pack-matrix.mjs selects. + { define: "SCR_PROCESS_EXTRAS", when: (opts) => opts.processExtras }, + { define: "SCR_NET_SERVER_OFF", when: (opts) => opts.processExtras }, + { define: "SCR_FS_FLAGS", when: (opts) => opts.fsFlags }, // scr_async.c's native promise reactions, which the web streams unit uses, // scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes, // and scr_fetch.c's bodies of its streams. diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..141fad738 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -65,6 +65,9 @@ export interface EarlyExecutableNativeFeatures { wasm?: boolean; warningReplace?: boolean; stdioExtended?: boolean; + fsFlags?: boolean; + strdecDyn?: boolean; + processExtras?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..c9ca2ffa9 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -700,6 +700,9 @@ async function compileExecutableNative( ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.fsFlags ? { fsFlags: true } : {}), + ...(features.strdecDyn ? { strdecDyn: true } : {}), + ...(features.processExtras ? { processExtras: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 2c9f6d57f..f0bb7bea6 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1248,6 +1248,33 @@ const RUNTIME_FEATURE_RULES = { "tls.sockGetCertificate", ], }), + /** writeFile's `flag` disposition ("w" / "wx") and copyFile's mode + * argument: scr_fs_flags.c and the SCR_FS_FLAGS build of scr_fs_cp.c. + * Programs that never pass either keep both units' default objects. */ + fsFlags: rule({ + fns: [ + "fs.copyFileFlagsSync", + "fs.writeFileFlagsSync", + "fsp.copyFileFlags", + "fsp.writeFileFlags", + ], + }), + /** string_decoder's checked-dynamic chunk forms (a stream "data" payload): + * scr_bytes_strdec_dyn.c. */ + strdecDyn: rule({ fns: ["strdec.nextDyn", "strdec.writeDyn"] }), + /** The process and stream event registries' extras — prependListener for + * signals, 'exit' and uncaught exceptions, stdin.isRaw, and server.off: + * the SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and + * the SCR_NET_SERVER_OFF build of scr_net.c. */ + processExtras: rule({ + fns: [ + "net.serverOff", + "process.prependExit", + "process.prependSignal", + "process.prependUncaughtException", + "process.stdinIsRaw", + ], + }), zlib: rule({ prefixes: ["zlib."] }), /** node:zlib's Brotli forms: zlib.brotliInstall, which %main calls when * the program uses Brotli (scr_brotli.c). */ @@ -1289,6 +1316,7 @@ function noRuntimeFeatures(): RuntimeFeatures { eventsOnceSignal: false, fetch: false, fileHandle: false, + fsFlags: false, fsWatch: false, http2: false, http: false, @@ -1308,6 +1336,7 @@ function noRuntimeFeatures(): RuntimeFeatures { pki: false, ed25519: false, processEvents: false, + processExtras: false, qs: false, regex: false, searchParams: false, @@ -1316,6 +1345,7 @@ function noRuntimeFeatures(): RuntimeFeatures { moduleNamespace: false, moduleNamespaceDyn: false, stream: false, + strdecDyn: false, streamDyn: false, handleProps: false, childLifecycle: false, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index d83d8393c..478ee5d24 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -62,6 +62,12 @@ export const BASE_RUNTIME_SOURCES = [ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_copying.c", "copying"], ["scr_file_handle.c", "fileHandle"], + // writeFile's `flag` disposition and the fs/promises twins; the copyFile + // mode form is the SCR_FS_FLAGS build of scr_fs_cp.c. + ["scr_fs_flags.c", "fsFlags"], + // string_decoder's checked-dynamic chunk forms, apart from scr_bytes.c's + // typed twins so a typed program keeps that object byte for byte. + ["scr_bytes_strdec_dyn.c", "strdecDyn"], ["scr_regex.c", "regex"], ["scr_glob.c", "regex"], ["scr_idna.c", "regex"], @@ -278,6 +284,37 @@ export function variantsFor(source) { when: { warningReplace: true }, defines: ["SCR_WARNING_REPLACE"], }); + // The process-prepend registries (processExtras) add + // scr_process_prepend_uncaught_exception, in a build of its own so the + // default object stays put for every program. + variants.push({ + id: "process-extras", + when: { processExtras: true }, + defines: ["SCR_PROCESS_EXTRAS"], + }); + variants.push({ + id: "warning-replace-process-extras", + when: { warningReplace: true, processExtras: true }, + defines: ["SCR_WARNING_REPLACE", "SCR_PROCESS_EXTRAS"], + }); + } + // copyFileSync's mode argument reuses scr_fs_cp.c's reflink walk, so the + // entry lives in a build of this unit rather than a unit of its own. + if (source === "scr_fs_cp.c") { + variants.push({ + id: "flags", + when: { fsFlags: true }, + defines: ["SCR_FS_FLAGS"], + }); + } + // server.off / removeListener: the listener-removal entry, in a build of + // its own so a program that never removes a listener keeps the default. + if (source === "scr_net.c") { + variants.push({ + id: "server-off", + when: { processExtras: true }, + defines: ["SCR_NET_SERVER_OFF"], + }); } // Programs with web streams link the promise unit with native promise // reactions, the builtin-class unit whose instanceof knows their classes @@ -331,6 +368,29 @@ export function variantsFor(source) { when: { stdioExtended: true, nodeIsland: true }, defines: ["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"], }); + // processExtras adds the prepend registrations and stdin's raw-mode + // flag; the combinations above then need their own builds, since the + // variant with the most conditions is the one selected. + variants.push({ + id: "process-extras", + when: { processExtras: true }, + defines: ["SCR_PROCESS_EXTRAS"], + }); + variants.push({ + id: "process-extras-stdio-extended", + when: { processExtras: true, stdioExtended: true }, + defines: ["SCR_PROCESS_EXTRAS", "SCR_STDIO_EXTENDED"], + }); + variants.push({ + id: "process-extras-node-island", + when: { processExtras: true, nodeIsland: true }, + defines: ["SCR_PROCESS_EXTRAS", "SCR_NODE_ISLANDS"], + }); + variants.push({ + id: "process-extras-stdio-extended-node-island", + when: { processExtras: true, stdioExtended: true, nodeIsland: true }, + defines: ["SCR_PROCESS_EXTRAS", "SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"], + }); } // Programs with Node islands link the event loop with the Node island slot; // every other program keeps the default object, byte for byte. diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index f26d520f9..0225269f4 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3234,11 +3234,6 @@ ScrPromise *scr_fsp_write_file_mode(ScrStr *path, ScrStr *data, double mode) { return scr_promise_settled_void(); } -ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { - scr_fs_write_file_flags(path, data, mode, exclusive); - return scr_promise_settled_void(); -} - ScrPromise *scr_fsp_mkdir(ScrStr *path) { scr_fs_mkdir(path); return scr_promise_settled_void(); @@ -3299,11 +3294,6 @@ ScrPromise *scr_fsp_copyfile(ScrStr *src, ScrStr *dest) { return scr_promise_settled_void(); } -ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { - scr_fs_copyfile_flags(src, dest, mode); - return scr_promise_settled_void(); -} - /* readFile(path, encoding | { encoding }): the encoding is checked first, * so an unknown name rejects before the read. */ ScrPromise *scr_fsp_read_file_encoded(ScrStr *path, ScrStr *encoding) { diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 99c17eeca..6ad771b17 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -341,11 +341,7 @@ static bool scr_rej_check_listener(ScrDyn *fn) { return false; } -/* The registration body: `prepend` puts the listener in FRONT of the - * registry's existing entries (process.prependListener — Node's - * EventEmitter order), the plain form appends. */ -static void scr_rej_push_at(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once, - bool prepend) { +static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { if (*n == *cap) { *cap = *cap ? *cap * 2 : 4; *list = realloc(*list, *cap * sizeof **list); @@ -354,18 +350,7 @@ static void scr_rej_push_at(ScrRejListener **list, size_t *n, size_t *cap, ScrDy ScrRejState *state = calloc(1, sizeof *state); if (!state) scr_ad_oom(); state->refs = 1; - ScrRejListener entry = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; - if (prepend) { - memmove(*list + 1, *list, *n * sizeof **list); - (*list)[0] = entry; - (*n)++; - } else { - (*list)[(*n)++] = entry; - } -} - -static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { - scr_rej_push_at(list, n, cap, fn, once, false); + (*list)[(*n)++] = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; } static void scr_rej_remove(ScrRejListener *list, size_t *n, ScrDyn *fn) { @@ -482,32 +467,32 @@ static void scr_uncaught_teardown(void) { scr_uncaught_sync_hook(); } -/* Arm the registry's exit-time teardown once, whichever entry point - * registers first. */ -static void scr_uncaught_arm(void) { - static SCR_TL bool armed = false; - if (armed) return; - armed = true; - scr_atexit(scr_uncaught_teardown); -} - void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor) { if (!scr_rej_check_listener(fn)) return; - scr_uncaught_arm(); + static SCR_TL bool armed = false; + if (!armed) { armed = true; scr_atexit(scr_uncaught_teardown); } size_t k = monitor ? 1 : 0; scr_rej_push(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, once); scr_uncaught_sync_hook(); } +#ifdef SCR_PROCESS_EXTRAS /* process.prependListener("uncaughtException"[Monitor], fn): the same - * listener in front of the registry's existing entries. */ + * listener in FRONT of the registry's existing entries (Node's + * EventEmitter order). The append path above registers it and the new entry + * then moves to the front, so the two share one registration; a refused + * listener (a non-function) left the list alone and nothing moves. */ void scr_process_prepend_uncaught_exception(ScrDyn *fn, bool monitor) { - if (!scr_rej_check_listener(fn)) return; - scr_uncaught_arm(); size_t k = monitor ? 1 : 0; - scr_rej_push_at(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, false, true); - scr_uncaught_sync_hook(); + size_t before = scr_uncaught_n[k]; + scr_process_on_uncaught_exception(fn, false, monitor); + if (scr_uncaught_n[k] != before + 1) return; + ScrRejListener entry = scr_uncaught_ls[k][scr_uncaught_n[k] - 1]; + memmove(scr_uncaught_ls[k] + 1, scr_uncaught_ls[k], + (scr_uncaught_n[k] - 1) * sizeof *scr_uncaught_ls[k]); + scr_uncaught_ls[k][0] = entry; } +#endif void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor) { if (!scr_rej_check_listener(fn)) return; diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 3e7277dc7..6bb56195a 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -1509,45 +1509,6 @@ double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk) return hold; } -/* The bytes view of a checked-dynamic write chunk, or NULL for a string - * chunk (Node's write returns it unchanged, never touching the decoder). - * Anything that is not a string or an ArrayBufferView throws Node's - * ERR_INVALID_ARG_TYPE for "buf". */ -static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { - *is_string = chunk->kind == SCR_DYN_STR; - if (*is_string) return NULL; - if ( - chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || - scr_buffer_storage_is(chunk)) { - return scr_bytes_buffer_source(chunk); - } - scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); - return NULL; -} - -/* decoder.write(chunk) where the chunk crossed the checked-dynamic - * boundary (`stream.on("data", (data) => decoder.write(data))`). */ -ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { - bool is_string = false; - ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); - if (is_string) return scr_str_retain(chunk->v.str); - if (!view) return NULL; - ScrStr *out = scr_strdec_write(enc, pending, view); - scr_bytes_release(view); - return out; -} - -/* The decoder state after that write: a string chunk leaves it alone. */ -double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { - bool is_string = false; - ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); - if (is_string) return pending; - if (!view) return 0; - double hold = scr_strdec_next(enc, pending, view); - scr_bytes_release(view); - return hold; -} - /* decoder.end(): the buffered partial flushes — utf8's truncated sequence * becomes its replacement chars (the maximal-subpart rule), base64's * remainder encodes (padded; unpadded for base64url), utf16le's held diff --git a/packages/runtime/src/scr_bytes_strdec_dyn.c b/packages/runtime/src/scr_bytes_strdec_dyn.c new file mode 100644 index 000000000..1c9bd2998 --- /dev/null +++ b/packages/runtime/src/scr_bytes_strdec_dyn.c @@ -0,0 +1,50 @@ +/* string_decoder's checked-dynamic chunk forms. + * + * A stream's "data" payload crosses the checked-dynamic boundary, so + * decoder.write and decoder.end receive it as an ScrDyn: a string passes + * through unchanged without touching the buffered state, an ArrayBufferView + * (Buffer, TypedArray, DataView) decodes through scr_bytes.c's per-encoding + * helpers with the packed-f64 pending state, and anything else throws Node's + * ERR_INVALID_ARG_TYPE for "buf". They live in their own unit because the + * static twins in scr_bytes.c belong only to programs whose chunks are + * already typed; those programs keep that object byte for byte. */ +#include "scr_runtime.h" + +/* The bytes view of a checked-dynamic write chunk, or NULL for a string + * chunk (Node's write returns it unchanged, never touching the decoder). + * Anything that is not a string or an ArrayBufferView throws Node's + * ERR_INVALID_ARG_TYPE for "buf". */ +static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { + *is_string = chunk->kind == SCR_DYN_STR; + if (*is_string) return NULL; + if ( + chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || + scr_buffer_storage_is(chunk)) { + return scr_bytes_buffer_source(chunk); + } + scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); + return NULL; +} + +/* decoder.write(chunk) where the chunk crossed the checked-dynamic + * boundary (`stream.on("data", (data) => decoder.write(data))`). */ +ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return scr_str_retain(chunk->v.str); + if (!view) return NULL; + ScrStr *out = scr_strdec_write(enc, pending, view); + scr_bytes_release(view); + return out; +} + +/* The decoder state after that write: a string chunk leaves it alone. */ +double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return pending; + if (!view) return 0; + double hold = scr_strdec_next(enc, pending, view); + scr_bytes_release(view); + return hold; +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index a159c2fdc..aa550a5f3 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -192,10 +192,7 @@ static bool scr_signal_check(ScrStr *name, ScrDyn *cb) { return true; } -/* The registration body both entry points share: `prepend` puts the - * listener in FRONT of the signal's existing ones (process.prependListener - * — Node's EventEmitter order), the plain form appends. */ -static void scr_signal_add(ScrStr *name, ScrDyn *cb, bool once, bool prepend) { +void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { if (!scr_signal_check(name, cb)) return; if (!scr_context_is_main()) return; #ifdef __wasi__ @@ -245,25 +242,37 @@ static void scr_signal_add(ScrStr *name, ScrDyn *cb, bool once, bool prepend) { reg->ls = realloc(reg->ls, reg->cap * sizeof *reg->ls); if (!reg->ls) scr_events_oom(); } - ScrSigListener entry = - (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; - if (prepend) { - memmove(reg->ls + 1, reg->ls, reg->n * sizeof *reg->ls); - reg->ls[0] = entry; - reg->n++; - } else { - reg->ls[reg->n++] = entry; - } + reg->ls[reg->n++] = (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; #endif } -void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { - scr_signal_add(name, cb, once, false); +#ifdef SCR_PROCESS_EXTRAS +/* process.prependListener(, cb): the same listener in FRONT of the + * signal's existing registrations (Node's EventEmitter order). The append + * path above registers it and the new entry then moves to the front, so the + * two share one registration; a refused or inert registration (a throw, a + * non-main context) left the list alone and nothing moves. */ +void scr_signal_prepend(ScrStr *name, ScrDyn *cb) { +#ifdef __wasi__ + scr_signal_on(name, cb, false); /* traps, exactly as the append form does */ +#else + int sig = scr_signal_from_name(name); + if (sig < 0) sig = 0; + ScrSigReg *reg = scr_sig_regs; + while (reg && reg->sig != sig) reg = reg->next; + size_t before = reg ? reg->n : 0; + scr_signal_on(name, cb, false); + if (!reg) { + reg = scr_sig_regs; + while (reg && reg->sig != sig) reg = reg->next; + } + if (!reg || reg->n != before + 1) return; + ScrSigListener entry = reg->ls[reg->n - 1]; + memmove(reg->ls + 1, reg->ls, (reg->n - 1) * sizeof *reg->ls); + reg->ls[0] = entry; +#endif } - -/* process.prependListener(, cb): the same listener in front of the - * signal's existing registrations. */ -void scr_signal_prepend(ScrStr *name, ScrDyn *cb) { scr_signal_add(name, cb, false, true); } +#endif static void scr_sig_reg_drop(ScrSigReg *reg) { if (reg->sig > 0) { @@ -1708,6 +1717,31 @@ void scr_process_on_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, doub scr_exit_n++; } +#ifdef SCR_PROCESS_EXTRAS +/* process.stdin.isRaw: the raw-mode flag of a TTY stdin (1/0); a + * pipe-backed stdin has no such flag and answers Node's undefined, which + * the emitter's union construction spells -1. */ +double scr_process_stdin_is_raw(void) { + if (!scr_context_is_main()) return -1; + if (!scr_process_is_tty(0)) return -1; + return scr_stdio_raw ? 1.0 : 0.0; +} + +/* process.prependListener("exit", cb): the same listener in FRONT of the + * registry's existing entries (Node's EventEmitter order). The append path + * above registers it and the new entry then moves to the front, so the two + * share one registration; a registry already swept at exit released the + * closure and left the list empty, and nothing moves. */ +void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { + size_t before = scr_exit_n; + scr_process_on_exit(cb, fn, false); + if (scr_exit_n != before + 1) return; + ScrExitListener entry = scr_exit_ls[scr_exit_n - 1]; + memmove(scr_exit_ls + 1, scr_exit_ls, (scr_exit_n - 1) * sizeof *scr_exit_ls); + scr_exit_ls[0] = entry; +} +#endif + #ifdef SCR_NODE_ISLANDS /* Island code's 'exit' listeners, at their registration position among * the program's own. */ @@ -1734,37 +1768,6 @@ void scr_process_off_exit_island(uint32_t island) { } #endif -/* process.stdin.isRaw: the raw-mode flag of a TTY stdin (1/0); a - * pipe-backed stdin has no such flag and answers Node's undefined, which - * the emitter's union construction spells -1. */ -double scr_process_stdin_is_raw(void) { - if (!scr_context_is_main()) return -1; - if (!scr_process_is_tty(0)) return -1; - return scr_stdio_raw ? 1.0 : 0.0; -} - -/* process.prependListener("exit", cb): the same listener in front of the - * registry's existing entries. */ -void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { - if (scr_exit_ran) { - scr_closure_release(cb); - return; - } - if (scr_exit_n == scr_exit_cap) { - scr_exit_cap = scr_exit_cap ? scr_exit_cap * 2 : 2; - scr_exit_ls = realloc(scr_exit_ls, scr_exit_cap * sizeof *scr_exit_ls); - if (!scr_exit_ls) scr_events_oom(); - } - memmove(scr_exit_ls + 1, scr_exit_ls, scr_exit_n * sizeof *scr_exit_ls); - scr_exit_ls[0].cb = cb; - scr_exit_ls[0].fn = fn; - scr_exit_ls[0].once = false; -#ifdef SCR_NODE_ISLANDS - scr_exit_ls[0].island = 0; -#endif - scr_exit_n++; -} - void scr_process_off_exit(ScrClosure *cb /*borrowed*/) { for (size_t i = 0; i < scr_exit_n; i++) { #ifdef SCR_NODE_ISLANDS diff --git a/packages/runtime/src/scr_fs_cp.c b/packages/runtime/src/scr_fs_cp.c index f6c1fd13b..8c49dddb2 100644 --- a/packages/runtime/src/scr_fs_cp.c +++ b/packages/runtime/src/scr_fs_cp.c @@ -589,14 +589,19 @@ static void cp_copyfile(const ScrStr *src, const ScrStr *dest, int mode, const c if (e) scr_fs_throw2(e, syscall, src, dest); } +#if defined(SCR_FS_FLAGS) && !defined(_WIN32) /* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's * uv_fs_copyfile with Node's flag bits (the C++ binding's range check and * message: any value in [0, 7], truncated to an integer — 1.5 is EXCL). * COPYFILE_EXCL (1) fails EEXIST on an existing destination, * COPYFILE_FICLONE (2) requests a reflink and falls back to a copy, and - * COPYFILE_FICLONE_FORCE (4) fails where no reflink is possible. This file - * excludes _WIN32, whose arm (CopyFileW and its ENOSYS for FICLONE_FORCE) - * lives in scr_lib.c beside the Win32 fs helpers. */ + * COPYFILE_FICLONE_FORCE (4) fails where no reflink is possible. + * + * This is the SCR_FS_FLAGS build of the unit: cp_copyfile is the reflink + * walk, and keeping the entry behind a define leaves every program that + * never passes a mode with the default object, byte for byte. The Windows + * arm (CopyFileW and its ENOSYS for FICLONE_FORCE) sits beside the Win32 fs + * helpers in scr_lib.c. */ void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { if (!(mode >= 0 && mode <= 7)) { static const char text[] = "mode is out of range: >= 0 && <= 7"; @@ -607,6 +612,7 @@ void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { if (!scr_fs_path_ok(dest, "dest")) return; cp_copyfile(src, dest, (int)mode, "copyfile"); } +#endif /* utimes(dest, atime, mtime) in seconds, through the utimesSync path. */ static bool cp_utime(const ScrStr *dest, double atime, double mtime) { diff --git a/packages/runtime/src/scr_fs_flags.c b/packages/runtime/src/scr_fs_flags.c new file mode 100644 index 000000000..29f3e07d6 --- /dev/null +++ b/packages/runtime/src/scr_fs_flags.c @@ -0,0 +1,102 @@ +/* fs.writeFile's `flag` option ("w", "wx") and the fs/promises twins of the + * flag-aware write and copy forms. + * + * The write is scr_lib.c's scr_fs_write_file_mode body with the disposition + * flag added (the same portable open(2) calls, so it builds for every + * target). It lives in this unit rather than beside that function so a + * program that never passes `flag` keeps scr_lib.c's object byte for byte; + * the fs.copyFile mode form rides scr_fs_cp.c's SCR_FS_FLAGS build, whose + * reflink helper it reuses, and that form's Windows arm sits beside the + * Win32 path helpers in scr_lib.c. */ +#include "scr_runtime.h" + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#else +#include +#endif + +/* O_BINARY: Windows-only (CRT text mode would translate \n on fd writes); + * zero everywhere else. scr_lib.c carries the same guard for its writes. */ +#ifndef O_BINARY +#define O_BINARY 0 +#endif + +/* Node's mode validation for the options-record write: an integer in + * open(2)'s mode_t range. Throws ERR_OUT_OF_RANGE and answers false. */ +static bool scr_fs_write_mode_ok(double mode) { + char recv[48], msg[176]; + scr_num_received(mode, recv); + if (!(isfinite(mode) && trunc(mode) == mode)) { + int len = snprintf(msg, sizeof msg, + "The value of \"mode\" is out of range. It must be an integer. Received %s", + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + if (mode < 0 || mode > 4294967295.0) { + int len = snprintf(msg, sizeof msg, + "The value of \"mode\" is out of range. It must be >= 0 && <= 4294967295. Received %s", + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + return true; +} + +/* The open(2) write behind the flag form: O_TRUNC plus `extra` (only O_EXCL + * today), creation mode applying at CREATION only (umask applying) — an + * existing file keeps its permissions, exactly Node (which never chmods + * here). Same error shapes as the plain form. */ +static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int extra) { + if (!scr_fs_path_ok(path, "path")) return; + /* O_BINARY: zero on POSIX; on Windows it keeps the CRT from translating + * \n in these byte-exact writes (fopen's "wb" path already does). */ + int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | extra, mode); + if (fd < 0) { + scr_fs_throw(errno, "open", path); + return; + } + char *owned; + const char *bytes = scr_str_utf8(data, &owned); + size_t at = 0; + while (at < data->len) { + ssize_t wrote = write(fd, bytes + at, data->len - at); + if (wrote < 0) { + if (errno == EINTR) continue; + int e = errno; + close(fd); + scr_str_utf8_end(owned); + scr_fs_throw(e, "write", path); + return; + } + at += (size_t)wrote; + } + scr_str_utf8_end(owned); + if (close(fd) != 0) scr_fs_throw(errno, "close", path); +} + +/* writeFileSync(p, data, { mode, flag }): Node's open(2) with the + * disposition flag. `exclusive` is "wx" (O_EXCL: EEXIST when the path + * exists); 0 is "w", the default truncate-or-create. */ +void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + if (!scr_fs_write_mode_ok(mode)) return; + scr_fs_write_file_open(path, data, (mode_t)mode, exclusive != 0 ? O_EXCL : 0); +} + +/* fs.promises.writeFile(path, data, { mode, flag }) and + * fs.promises.copyFile(src, dest, mode): the settled-promise twins of + * fs.writeFileFlagsSync and fs.copyFileFlagsSync. */ +ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + scr_fs_write_file_flags(path, data, mode, exclusive); + return scr_promise_settled_void(); +} + +ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + scr_fs_copyfile_flags(src, dest, mode); + return scr_promise_settled_void(); +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index d4d84a6e8..418c3bfe2 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -3531,9 +3531,12 @@ void scr_fs_write_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "wb"); } -/* Node's mode validation for the options-record write: an integer in - * open(2)'s mode_t range. Throws ERR_OUT_OF_RANGE and answers false. */ -static bool scr_fs_write_mode_ok(double mode) { +/* writeFileSync(p, data, { mode }): the mode is open(2)'s O_CREAT + * argument — it applies at CREATION only (umask applying), and an + * existing file keeps its permissions, exactly Node (which never chmods + * here). Same error shapes as the plain form. */ +void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { + if (!scr_fs_path_ok(path, "path")) return; char recv[48], msg[176]; scr_num_received(mode, recv); if (!(isfinite(mode) && trunc(mode) == mode)) { @@ -3541,27 +3544,18 @@ static bool scr_fs_write_mode_ok(double mode) { "The value of \"mode\" is out of range. It must be an integer. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return false; + return; } if (mode < 0 || mode > 4294967295.0) { int len = snprintf(msg, sizeof msg, "The value of \"mode\" is out of range. It must be >= 0 && <= 4294967295. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return false; + return; } - return true; -} - -/* The open(2) write behind the mode/flag forms: O_TRUNC plus `extra` (only - * O_EXCL today), creation mode applying at CREATION only (umask applying) — - * an existing file keeps its permissions, exactly Node (which never chmods - * here). Same error shapes as the plain form. */ -static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int extra) { - if (!scr_fs_path_ok(path, "path")) return; /* O_BINARY: zero on POSIX; on Windows it keeps the CRT from translating * \n in these byte-exact writes (fopen's "wb" path already does). */ - int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | extra, mode); + int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, (mode_t)mode); if (fd < 0) { scr_fs_throw(errno, "open", path); return; @@ -3585,22 +3579,6 @@ static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int if (close(fd) != 0) scr_fs_throw(errno, "close", path); } -/* writeFileSync(p, data, { mode }). */ -void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { - if (!scr_fs_path_ok(path, "path")) return; - if (!scr_fs_write_mode_ok(mode)) return; - scr_fs_write_file_open(path, data, (mode_t)mode, 0); -} - -/* writeFileSync(p, data, { mode, flag }): Node's open(2) with the - * disposition flag. `exclusive` is "wx" (O_EXCL: EEXIST when the path - * exists); 0 is "w", the default truncate-or-create. */ -void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { - if (!scr_fs_path_ok(path, "path")) return; - if (!scr_fs_write_mode_ok(mode)) return; - scr_fs_write_file_open(path, data, (mode_t)mode, exclusive != 0 ? O_EXCL : 0); -} - void scr_fs_append_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "ab"); } @@ -4694,6 +4672,12 @@ static bool scr_fs_win_same_file(const WCHAR *a, const WCHAR *b) { first.nFileIndexLow == second.nFileIndexLow; } +/* The Win32 arm of copyFile's mode form (scr_fs_cp.c's SCR_FS_FLAGS build + * has the POSIX one; writeFile's flag form is portable and lives in + * scr_fs_flags.c). It sits here because the wide-path and errno helpers + * above are this file's; _WIN32 excludes it from the POSIX builds, so every + * other platform's scr_lib.c object is unchanged. */ + /* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's * fs__copyfile in src/win/fs.c. CopyFileW carries only COPYFILE_EXCL (its * bFailIfExists flag); COPYFILE_FICLONE (2) falls back to a plain copy and diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index c17c8b004..477fd9ab7 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -2438,9 +2438,12 @@ void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool onc scr_net_ls_add(&s->close_ls, cb, NULL, once); } +#ifdef SCR_NET_SERVER_OFF /* server.off(event, cb) / removeListener: the LAST listener standing for * the same function leaves the event's list — Node's EventEmitter removes - * the most recently added match, so the scan runs back to front. */ + * the most recently added match, so the scan runs back to front. The + * SCR_NET_SERVER_OFF build of the unit carries it, so a program that never + * removes a listener keeps the default object byte for byte. */ void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kind) { ScrNetLs *l = kind == SCR_NET_OFF_ERROR ? &s->err_ls : kind == SCR_NET_OFF_CLOSE ? &s->close_ls @@ -2461,6 +2464,7 @@ void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kin return; } } +#endif /* server.on('listening', cb): joins the deferred 'listening' list the * listen(port, cb) callback rides (the sweep fires it once after a From 51dc23165628ba5bc915c1a616edfec13dbe4b27 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:24:13 -0700 Subject: [PATCH 117/193] Keep static private members in native storage behind a brand check A JavaScript class with a public static accessor gets its constructor object at run time, but a private static member kept the whole class off that path, so the Test262 shape `static get m() { return this.#m; }` refused. Defining the privates on the constructor object instead would make them string keys, readable through any subclass. Private statics now keep their native storage (the static function and module global), initialized in source order with the public statics, and an access through a receiver other than the class's own name first checks that the receiver is the declaring class object, throwing V8's TypeError otherwise. The class-name spelling is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-runtime-statics.ts | 15 + .../frontend/lowering/expressions/binary.ts | 9 +- .../lowering/expressions/property-access.ts | 7 + .../src/frontend/lowering/lower-calls.ts | 3 + .../src/frontend/lowering/lower-classes.ts | 50 ++- .../src/frontend/lowering/lower-stmts.ts | 4 + .../src/frontend/lowering/private-statics.ts | 297 ++++++++++++++++++ 7 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/private-statics.ts diff --git a/packages/compiler/src/frontend/lowering/class-runtime-statics.ts b/packages/compiler/src/frontend/lowering/class-runtime-statics.ts index 707c916ec..2041fb71c 100644 --- a/packages/compiler/src/frontend/lowering/class-runtime-statics.ts +++ b/packages/compiler/src/frontend/lowering/class-runtime-statics.ts @@ -100,6 +100,21 @@ export function initializeRuntimeStatics( } if (!member.name) continue; const memberLoc = locOf(member); + // A private static field: its native global, assigned in order. + if (ts.isPrivateIdentifier(member.name)) { + const name = member.name.text; + const field = info.staticFields.find((candidate) => candidate.name === name); + if (!ts.isPropertyDeclaration(member) || !field) { + lowerer.unsupported("SC1090", member, "private static fields without an initializer"); + } + statements.push({ + kind: "assign", + localId: field.globalId, + value: lowerer.lowerExprExpecting(member.initializer!, field.type), + loc: memberLoc, + }); + continue; + } const key: IrExpr = ts.isComputedPropertyName(member.name) ? computedKeys.get(member)! : lowerer.coerceToExpected( diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index e20604f92..012b9a562 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1,4 +1,5 @@ /* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */ +import { lowerStaticPrivateAssignment } from "../private-statics.js"; import { keyedHelperName } from "../stable-names.js"; import * as ts from "../../ts7/adapter.js"; import { @@ -632,7 +633,11 @@ export function lowerNativeFunctionAssignment( const classType = storedClassValueType(lowerer, target.expression); const staticClass = classType?.kind === "classval" ? lowerer.classes.get(classType.className) : undefined; - if (staticClass && hasRuntimeStatics(staticClass)) { + if ( + staticClass && + hasRuntimeStatics(staticClass) && + !(ts.isPropertyAccessExpression(target) && ts.isPrivateIdentifier(target.name)) + ) { return lowerDynMemberAssignment( lowerer, expr, @@ -1110,6 +1115,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (op === ts.SyntaxKind.EqualsToken) { const prototypeWrite = lowerClassPrototypeComputedAssignment(lowerer, expr); if (prototypeWrite) return prototypeWrite; + const staticPrivateWrite = lowerStaticPrivateAssignment(lowerer, expr); + if (staticPrivateWrite) return staticPrivateWrite; const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); if (callableWrite) return callableWrite; const dereferenced = lowerDereferencedElementAssignment(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf1013..1881b9402 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,4 +1,5 @@ import { widensReducedRecords } from "../function-returns.js"; +import { lowerStaticPrivateRead } from "../private-statics.js"; import { widthNarrowHelper } from "../coercions/unions.js"; /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import { jsSpecialGlobalValue } from "../global-values.js"; @@ -344,6 +345,12 @@ export function lowerPropertyAccessExpr( ): IrExpr { const arrayClassConstructor = lowerArrayClassConstructorRead(lowerer, expr, loc); if (arrayClassConstructor !== null) return arrayClassConstructor; + // `R.#x` for a static private on a checked-dynamic receiver: the brand + // check, then the native storage (private-statics.ts). + { + const staticPrivate = lowerStaticPrivateRead(lowerer, expr, loc); + if (staticPrivate) return staticPrivate; + } // `value.#x` on a checked-dynamic value, or on a checked extraction of // one: the field of the brand-checked receiver (checkedPrivateValue). if (ts.isPrivateIdentifier(expr.name)) { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e9d79213c..a0e00179b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5,6 +5,7 @@ import { } from "./builtins/array-classes.js"; import { structuralCallInstance } from "./structural-instances.js"; import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js"; +import { lowerStaticPrivateCall } from "./private-statics.js"; import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; @@ -669,6 +670,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { ts.isPrivateIdentifier(expr.expression.name) && !expr.expression.questionDotToken ) { + const staticPrivate = lowerStaticPrivateCall(lowerer, expr, expr.expression); + if (staticPrivate) return staticPrivate; const privateCall = lowerCheckedPrivateMethodCall(lowerer, expr, expr.expression); if (privateCall) return privateCall; } diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 4c8743d32..89b788709 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2452,9 +2452,15 @@ export function collectClassShapeInner( (modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword, ), )) && + // A private static ACCESSOR has no native storage yet, and a private + // static method that reads `arguments` would lose the arguments past + // its parameters when called through its value (`this.#m` returned by + // a getter): keep such a class on the static path, whose public + // static accessors refuse. !decl.members.nodes.some( (member) => - member.name && + (ts.isAccessor(member) || + (ts.isMethodDeclaration(member) && bodyReadsArguments(member))) && ts.isPrivateIdentifier(member.name) && ts .getModifiers(member) @@ -2523,8 +2529,22 @@ export function collectClassShapeInner( .getModifiers(member) ?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)) ) { - runtimeStatics.push(member); - continue; + // A private static of a declaration-level class keeps its native + // storage (the %C.static:#m function, the %g.s.C.#x global): the + // constructor object never carries a property for it, and every + // access brand-checks its receiver (private-statics.ts). A field + // also stays in runtimeStatics, so its initializer runs in source + // order with the constructor's other initializers. + const privateStatic = + checkedStatics && + !functionLocalClass(decl) && + (member as { name?: ts.PropertyName }).name !== undefined && + ts.isPrivateIdentifier((member as { name: ts.PropertyName }).name); + if (!privateStatic) { + runtimeStatics.push(member); + continue; + } + if (ts.isPropertyDeclaration(member)) runtimeStatics.push(member); } if ( functionLocalClass(decl) && @@ -5139,11 +5159,15 @@ function lowerStaticFieldInitsInner(lowerer: Lowerer, info: ClassInfo): IrStmt[] | { pos: number; kind: "field"; f: ClassInfo["staticFields"][number] } | { pos: number; kind: "block"; b: ts.ClassStaticBlockDeclaration }; const items: Item[] = [ - ...info.staticFields.map((f): Item => ({ - pos: ts.tokenStartOfNode(f.initializer), - kind: "field", - f, - })), + // A runtime-statics class initializes its private static fields with + // its other initializers (initializeRuntimeStatics). + ...info.staticFields + .filter((f) => !(info.runtimeStatics && f.name.startsWith("#"))) + .map((f): Item => ({ + pos: ts.tokenStartOfNode(f.initializer), + kind: "field", + f, + })), ...(info.staticBlocks ?? []).map((b): Item => ({ pos: ts.tokenStartOfNode(b), kind: "block", @@ -5848,7 +5872,7 @@ export function lowerClassStaticRead( !(ts.isCallExpression(site.parent) && site.parent.expression === site) ) lowerer.noLowering(`Date.${name} read through a class that extends Date`, site); - if (hasRuntimeStatics(info) && !found?.field) + if (hasRuntimeStatics(info) && !found?.field && !name.startsWith("#")) return { kind: "dynKeyGet", value: lowerer.coerceToExpected(classValueRef(lowerer, info, receiver), DYN), @@ -6746,7 +6770,9 @@ function staticCallOn( if (date) return date; } if ( - (hasRuntimeStatics(info) && !findStaticOn(lowerer, info, access.name.text)?.field) || + (hasRuntimeStatics(info) && + !access.name.text.startsWith("#") && + !findStaticOn(lowerer, info, access.name.text)?.field) || (info.callableBase && !findStaticOn(lowerer, info, access.name.text) && !findGenericStaticOn(lowerer, info, access.name.text)) @@ -7201,7 +7227,9 @@ export function lowerClassValueProperty( if (info?.def.runtime && member !== "name") return null; if ( info && - ((hasRuntimeStatics(info) && !findStaticOn(lowerer, info, member)?.field) || + ((hasRuntimeStatics(info) && + !member.startsWith("#") && + !findStaticOn(lowerer, info, member)?.field) || (info.callableBase && !findStaticOn(lowerer, info, member))) ) return { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 377b08917..d1436ea66 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,3 +1,4 @@ +import { lowerStaticPrivateAssignment } from "./private-statics.js"; import type { DiagnosticCode } from "../../diagnostics/codes.js"; import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js"; import { jsGlobalValueByName } from "./global-values.js"; @@ -8664,6 +8665,9 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm if (urlWrite) return { kind: "exprStmt", expr: urlWrite, loc: locOf(expr) }; } if (opKind === ts.SyntaxKind.EqualsToken) { + const staticPrivateWrite = lowerStaticPrivateAssignment(lowerer, expr); + if (staticPrivateWrite) + return { kind: "exprStmt", expr: staticPrivateWrite, loc: locOf(expr) }; const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); if (callableWrite) return { kind: "exprStmt", expr: callableWrite, loc: locOf(expr) }; // Expando function members (`foo.bar = 12`, `foo[SYM] = v` on a diff --git a/packages/compiler/src/frontend/lowering/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts new file mode 100644 index 000000000..7a7d56fbb --- /dev/null +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -0,0 +1,297 @@ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + VOID, + type IrExpr, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import { helperLoc, strLit, varRef } from "../../ir/build.js"; +import { locOf } from "../program.js"; +import { hasStaticThis } from "./static-this.js"; +import type { Lowerer } from "./lowerer.js"; +import { lowerClassStaticRead, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; +import { tryLowerExpression } from "./expressions/try-lower-expression.js"; + +/** How a static private name is used: a field read or write, or a method + * (or accessor) read or call. V8 words each failure differently. */ +export type StaticPrivateUse = "read" | "write" | "method"; + +/** The declaration-level class whose body declares `priv` as a STATIC + * member (the nearest enclosing class declaring the name, as JavaScript + * scoping resolves it). Null for an instance private, a function-local or + * generic class, or a class that was not collected. */ +export function staticPrivateOwner(lowerer: Lowerer, priv: ts.PrivateIdentifier): ClassInfo | null { + for (let node: ts.Node | undefined = priv.parent; node; node = node.parent) { + if (!ts.isClassDeclaration(node) && !ts.isClassExpression(node)) continue; + const member = node.members.nodes.find((candidate) => { + const name = (candidate as { name?: ts.PropertyName }).name; + return name !== undefined && ts.isPrivateIdentifier(name) && name.text === priv.text; + }); + if (!member) continue; + const isStatic = + ts.canHaveModifiers(member) && + ts.getModifiers(member)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true; + if (!isStatic) return null; + for (const info of lowerer.classes.values()) { + if (info.decl !== node) continue; + if (info.localClass || info.generic || info.genericInstance) return null; + return info; + } + return null; + } + return null; +} + +/** V8's TypeError text for a static private `name` of `info` used on a + * receiver that is not the declaring class object. */ +function brandMessage(info: ClassInfo, use: StaticPrivateUse, name: string): string { + if (use === "read") + return `Cannot read private member ${name} from an object whose class did not declare it`; + if (use === "write") + return `Cannot write private member ${name} to an object whose class did not declare it`; + // V8 names the class by its own identifier; an anonymous class + // expression prints ".". + return `Receiver must be class ${info.decl?.name?.text ?? "."}`; +} + +/** The brand check of a static private: JavaScript installs static private + * elements on the declaring class object alone, so the receiver must BE + * that object (a subclass, an object inheriting from it, or a primitive + * throws). An expression statement calling the lifted helper + * `%private.static.::<#name>(value: dyn): void`, which throws + * V8's TypeError; `value` is the checked-dynamic receiver. */ +export function staticPrivateBrandCheck( + lowerer: Lowerer, + info: ClassInfo, + use: StaticPrivateUse, + name: string, + value: IrExpr, + loc: SrcLoc, +): IrStmt { + const helper = `%private.static.${use}:${info.def.name}:${name}`; + if (!lowerer.valueHelpers.has(helper)) { + const at = helperLoc(loc); + const param = varRef("value.0", DYN, at); + const classValue = lowerer.coerceToExpected(lowerer.classValueRef(info, info.decl!), DYN); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "value.0", name: "value", type: DYN }], + returnType: VOID, + locals: [{ id: "value.0", name: "value", type: DYN, mutable: false }], + body: [ + { + kind: "if", + cond: { + kind: "dynScalarEq", + left: param, + right: classValue, + negated: true, + type: BOOL, + loc: at, + }, + then: [ + { + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [strLit(brandMessage(info, use, name), at)], + type: { kind: "object", className: "%TypeError" }, + loc: at, + }, + loc: at, + }, + ], + else_: null, + loc: at, + }, + ], + loc: at, + }); + lowerer.valueHelpers.set(helper, helper); + } + return { + kind: "exprStmt", + expr: { kind: "call", callee: helper, args: [value], type: VOID, loc }, + loc, + }; +} + +/** A static private the native storage does not hold (an accessor, a + * field without an initializer): a loud refusal, never a string-keyed + * read of the class object. */ +function unsupportedStaticPrivate(lowerer: Lowerer, node: ts.Node, name: string): never { + return lowerer.unsupported( + "SC1090", + node, + `the private static '${name}' in this form on a class whose constructor carries runtime statics`, + ); +} + +/** The checked-dynamic value `expr` lowers to, or null. */ +function checkedDynamicReceiver(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + const probed = tryLowerExpression(lowerer, node); + if (!probed) return null; + if ( + probed.type.kind !== "dyn" && + !(probed.kind === "dynCheck" && probed.value.type.kind === "dyn") + ) + return null; + const value = lowerer.lowerExpr(node); + if (value.type.kind === "dyn") return value; + return value.kind === "dynCheck" ? value.value : null; +} + +/** `R.#x` / `R.#m` read, where `#x` is a static private of a + * declaration-level class and `R` is a checked-dynamic value (`this` in a + * static accessor or method that the constructor object carries): the + * brand check, then the member's native storage. Null for other reads, + * which keep their paths (the class's own name reads storage directly). */ +export function lowerStaticPrivateRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + loc: SrcLoc, +): IrExpr | null { + if (!ts.isPrivateIdentifier(expr.name) || expr.questionDotToken) return null; + const info = staticPrivateOwner(lowerer, expr.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(expr.expression) === info) return null; + const value = checkedDynamicReceiver(lowerer, expr.expression); + if (!value) return null; + const name = expr.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found) unsupportedStaticPrivate(lowerer, expr, name); + const read = lowerClassStaticRead(lowerer, info, name, expr.expression, expr); + if (!read) return null; + return { + kind: "seqExpr", + stmts: [ + staticPrivateBrandCheck(lowerer, info, found.field ? "read" : "method", name, value, loc), + ], + result: read, + type: read.type, + loc, + }; +} + +/** `R.#m(args)` for a static private method on a checked-dynamic receiver: + * the brand check, then the call with `this` known to be the declaring + * class. A receiver other than `this` reaches only methods that do not + * read `this`; the others keep a loud refusal. */ +export function lowerStaticPrivateCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (!ts.isPrivateIdentifier(access.name) || access.questionDotToken) return null; + const info = staticPrivateOwner(lowerer, access.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(access.expression) === info) return null; + const name = access.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found) unsupportedStaticPrivate(lowerer, call, name); + if (found.field !== undefined) return null; + const value = checkedDynamicReceiver(lowerer, access.expression); + if (!value) return null; + const loc = locOf(call); + const brand = staticPrivateBrandCheck(lowerer, info, "method", name, value, loc); + let result: IrExpr | null; + if (access.expression.kind === ts.SyntaxKind.ThisKeyword) { + // After the check `this` IS the class: lower the call against a + // class-valued `this`, the exact-receiver static path. + const classType: IrType = { kind: "classval", className: info.def.name }; + const local = lowerer.declareHiddenLocal("%staticPrivateThis", classType); + const previous = lowerer.ctx.thisLocal; + lowerer.ctx.thisLocal = local; + try { + result = lowerStaticMethodCall(lowerer, call, access); + } finally { + lowerer.ctx.thisLocal = previous; + } + if (!result) return null; + return { + kind: "seqExpr", + stmts: [ + brand, + { + kind: "varDecl", + localId: local.id, + init: lowerer.coerceToExpected(lowerer.classValueRef(info, info.decl!), classType), + loc, + }, + ], + result, + type: result.type, + loc, + }; + } + if (found.method.member.body && hasStaticThis(found.method.member)) { + lowerer.unsupported( + "SC1090", + call, + `calling the private static '${name}' through a value other than 'this' when the method reads 'this'`, + ); + } + const fnName = `%${found.declarer.def.name}.static:${name}`; + lowerer.noteEdge(fnName); + const args = lowerer.completeArgs(call.arguments.nodes, found.method.params, loc, call); + return { + kind: "seqExpr", + stmts: [brand], + result: { kind: "call", callee: fnName, args, type: found.method.ret, loc }, + type: found.method.ret, + loc, + }; +} + +/** `R.#x = v` for a static private field on a checked-dynamic receiver: + * the receiver, then the value, then the brand check (PrivateSet checks + * after the right side runs), then the native global. */ +export function lowerStaticPrivateAssignment( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const target = expr.left; + if ( + expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isPropertyAccessExpression(target) || + !ts.isPrivateIdentifier(target.name) + ) + return null; + const info = staticPrivateOwner(lowerer, target.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(target.expression) === info) return null; + const name = target.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found?.field) unsupportedStaticPrivate(lowerer, expr, name); + const value = checkedDynamicReceiver(lowerer, target.expression); + if (!value) return null; + const loc = locOf(expr); + const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); + const assigned = lowerer.declareHiddenLocal("%staticPrivateValue", found.field.type); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiver.id, init: value, loc }, + { + kind: "varDecl", + localId: assigned.id, + init: lowerer.lowerExprExpecting(expr.right, found.field.type), + loc, + }, + staticPrivateBrandCheck(lowerer, info, "write", name, varRef(receiver.id, DYN, loc), loc), + { + kind: "assign", + localId: found.field.globalId, + value: varRef(assigned.id, found.field.type, loc), + loc, + }, + ], + result: varRef(assigned.id, found.field.type, loc), + type: found.field.type, + loc, + }; +} From 9f3fd5d9d912a11e1860899f6ca8bbcf0c7fe31a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:24:17 -0700 Subject: [PATCH 118/193] Cover static private receivers in the corpus and an IR unit test The corpus program runs under Node and natively with identical output: a getter returning a private method, private generator and async generator methods, private fields read and written through static accessors, a private method called through `this`, every subclass-receiver TypeError, receivers passed in as a value, the anonymous-class message, the absence of "#x" string keys, and source-order initialization of public and private static fields with a static block. The IR unit test pins that `this.#x` in a runtime static accessor calls the brand helper and that no private name becomes a string literal. Co-Authored-By: Claude Opus 5.5 --- .../test/private-static-receivers.test.ts | 69 ++++++++++ .../js-private-static-receivers.js.json | 6 + tests/corpus/js-private-static-receivers.js | 119 ++++++++++++++++++ .../js-private-static-receivers.js.json | 1 + 4 files changed, 195 insertions(+) create mode 100644 packages/compiler/test/private-static-receivers.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json create mode 100644 tests/corpus/js-private-static-receivers.js create mode 100644 tests/harness/corpus-costs/js-private-static-receivers.js.json diff --git a/packages/compiler/test/private-static-receivers.test.ts b/packages/compiler/test/private-static-receivers.test.ts new file mode 100644 index 000000000..7295e5592 --- /dev/null +++ b/packages/compiler/test/private-static-receivers.test.ts @@ -0,0 +1,69 @@ +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"; + +/** The names of the module's functions and of every call target. */ +async function lowered( + source: string, +): Promise<{ functions: string[]; callees: string[]; keys: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-private-statics-")); + try { + const entry = join(dir, "main.js"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const callees: string[] = []; + const keys: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown; callee?: unknown; value?: unknown }; + if (node.kind === "call" && typeof node.callee === "string") callees.push(node.callee); + if (node.kind === "strLit" && typeof node.value === "string" && node.value.startsWith("#")) + keys.push(node.value); + for (const child of Object.values(value)) visit(child); + }; + visit(mod.functions); + return { functions: mod.functions.map((fn) => fn.name), callees, keys }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a static private read through `this` in a runtime static accessor checks the brand", async () => { + const ir = await lowered( + `class C { + static #x = 1; + static #m() { return 2; } + static get x() { return this.#x; } + static get m() { return this.#m; } + } + console.log(C.x, C.m()); + `, + ); + expect(ir.callees).toContain("%private.static.read:C:#x"); + expect(ir.callees).toContain("%private.static.method:C:#m"); + // The private names never become string keys of the class object. + expect(ir.keys).toEqual([]); +}); + +test("the declaring class's own name reads native storage without a check", async () => { + const ir = await lowered( + `class C { + static #x = 1; + static get x() { return C.#x; } + } + console.log(C.x); + `, + ); + expect(ir.functions.some((name) => name.startsWith("%private.static."))).toBe(false); + expect(ir.keys).toEqual([]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json new file mode 100644 index 000000000..513a5bc93 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-private-static-receivers.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-private-static-receivers.js b/tests/corpus/js-private-static-receivers.js new file mode 100644 index 000000000..33b0acf97 --- /dev/null +++ b/tests/corpus/js-private-static-receivers.js @@ -0,0 +1,119 @@ +// Static private members of a JavaScript class whose constructor object is +// materialized at run time (it has a public static accessor). The private +// members keep their native storage; every access through a receiver other +// than the class's own name checks that the receiver IS the declaring class, +// as Node does: a subclass, an object inheriting from the class, or a +// primitive throws V8's TypeError. + +let calls = 0; +var Getter = class { + static #method(a, b) { + calls++; + return a + b; + } + static get method() { + return this.#method; + } +}; +console.log("getter method", Getter.method(1, 2), calls); + +var Generators = class { + static *#gen(n) { + yield n; + yield n + 1; + } + static async *#agen(n) { + yield n * 2; + } + static get gen() { + return this.#gen; + } + static get agen() { + return this.#agen; + } +}; +console.log("generator", [...Generators.gen(3)].join(",")); + +class Counter { + static #count = 5; + static #label() { + return "count:" + Counter.#count; + } + static get count() { + return this.#count; + } + static set count(value) { + this.#count = value; + } + static get label() { + return this.#label; + } + static describe() { + return this.#label(); + } +} +console.log("counter", Counter.count, Counter.label(), Counter.describe()); +Counter.count = 9; +console.log("after write", Counter.count, Counter.describe()); + +class Sub extends Counter {} +const probes = [ + ["Sub.count", () => Sub.count], + ["Sub.count =", () => { + Sub.count = 1; + }], + ["Sub.label", () => Sub.label], + ["Sub.describe()", () => Sub.describe()], +]; +for (const [label, probe] of probes) { + try { + console.log(label, "returned", String(probe())); + } catch (error) { + console.log(label, error.constructor.name, error.message); + } +} +console.log("unchanged", Counter.count); + +// A private name is never a string key of the class object. +console.log("no string key", Object.hasOwn(Counter, "#count"), "#count" in Counter, Object.hasOwn(Getter, "#method")); + +// Static initializers run in source order, private ones included. +const order = []; +class Ordered { + static a = (order.push("a"), 1); + static #b = (order.push("#b"), Ordered.a + 1); + static c = (order.push("c"), Ordered.#b + 1); + static { + order.push("block " + Ordered.#b); + } + static get all() { + return [this.a, this.#b, this.c].join(","); + } +} +console.log("order", order.join(","), Ordered.all); + +// A receiver passed in as a value: the same check. V8 names an anonymous +// class expression ".". +var Anonymous = class { + static #m() { + return "m"; + } + static get m() { + return this.#m; + } + static probe(other) { + return typeof other.#m; + } +}; +console.log("probe self", Anonymous.probe(Anonymous), Anonymous.m()); +for (const other of [{}, 1, undefined]) { + try { + Anonymous.probe(other); + } catch (error) { + console.log("probe", typeof other, error.constructor.name, error.message); + } +} + +Generators.agen(4) + .next() + .then((step) => console.log("async generator", step.value, step.done)); diff --git a/tests/harness/corpus-costs/js-private-static-receivers.js.json b/tests/harness/corpus-costs/js-private-static-receivers.js.json new file mode 100644 index 000000000..19c5a4186 --- /dev/null +++ b/tests/harness/corpus-costs/js-private-static-receivers.js.json @@ -0,0 +1 @@ +{ "plain": 2124, "san": 75913 } From fd7be9993da3873764126f4c736ebcef1ff51162 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:40:42 -0700 Subject: [PATCH 119/193] Add the primitive wrapper unit and its gated runtime feature scr_js_wrapper.c constructs `new String/Number/Boolean` wrappers and ToObject of a primitive as ordinary dictionary-mode objects whose prototype is the unit's String, Number or Boolean prototype, with the [[PrimitiveValue]] slot on the object's source identity. The unit also answers instanceof, Object.prototype.toString, the util.types brands, the String method receiver, and the guard of the graph-walking consumers that cannot read the slot yet. A new `primitiveWrapper` runtime feature (any `wrapper.` libCall) links it, so a program that never creates a wrapper links no byte of the unit and the zero-cost gate keeps fully typed programs clean. The `wrapper.*` signatures, symbols and dispatch case are registered here; no frontend emits them yet. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-dispatch.ts | 3 + .../compiler/src/backend/llvm/lib-shared.ts | 9 + .../src/backend/native-link-info-core.ts | 3 + .../compiler/src/backend/native/contracts.ts | 2 + .../compiler/src/backend/native/executable.ts | 2 + .../compiler/src/backend/runtime-pack-core.ts | 2 + .../src/executable/executable-cache.ts | 1 + packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 9 + .../compiler/src/ir/builtin-signatures.ts | 26 + packages/compiler/src/ir/runtime-features.ts | 8 + .../compiler/src/native/sanitizer.test.ts | 1 + .../runtime-pack-matrix.mjs | 2 + packages/runtime/src/scr_dyn.h | 18 + packages/runtime/src/scr_js_wrapper.c | 516 ++++++++++++++++++ 16 files changed, 604 insertions(+) create mode 100644 packages/runtime/src/scr_js_wrapper.c diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 15cab8778..3782f1e90 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -23,6 +23,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), + ...(features.primitiveWrapper ? { primitiveWrapper: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 8378fabea..a5099b99a 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -1272,6 +1272,9 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { return host.emitGenericLibCall(e); case "nodeIsland": return host.emitGenericLibCall(e); + // Primitive wrapper objects (scr_js_wrapper.c). + case "wrapper": + return host.emitGenericLibCall(e); case "webstream": // A stream's start, pull and cancel steps run as promise jobs, so // a program with the classes runs its event loop. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 73fd0b799..284d5e9f4 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -307,6 +307,15 @@ export const LIB_FN_SYMS: Record = { "process.builtinUnsupported": "scr_process_builtin_unsupported", "module.namespaceValue": "scr_module_namespace_value", "module.namespaceDyn": "scr_module_namespace_dyn_value", + "wrapper.new": "scr_js_wrapper_new", + "wrapper.toObject": "scr_js_wrapper_to_object", + "wrapper.instanceOf": "scr_js_wrapper_instance_of", + "wrapper.objectTag": "scr_js_wrapper_object_tag", + "wrapper.typeIs": "scr_js_wrapper_type_is", + "wrapper.guard": "scr_js_wrapper_guard", + "wrapper.guardList": "scr_js_wrapper_guard_list", + "wrapper.isStringMethod": "scr_js_wrapper_is_string_method", + "wrapper.thisString": "scr_js_wrapper_this_string", "fs.callbackValue": "scr_fs_callback_value", "fs.callbackCall": "scr_fs_callback_call", "process.hrtimeValue": "scr_process_hrtime_value", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index ded050bce..c2e2fc194 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -43,6 +43,9 @@ export interface NativeLinkFeatures { /** A module namespace boxed for typed code links * scr_module_namespace_dyn.c; absent (false) otherwise. */ moduleNamespaceDyn?: boolean; + /** Primitive wrapper objects link scr_js_wrapper.c; absent (false) + * otherwise. */ + primitiveWrapper?: boolean; zlib: boolean; /** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */ brotli?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 5026fd5f2..d1c6b6b9b 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -170,6 +170,8 @@ export interface CcOptions { /** A module namespace boxed for typed code: compiles * scr_module_namespace_dyn.c. */ moduleNamespaceDyn?: boolean; + /** Primitive wrapper objects: compiles scr_js_wrapper.c. */ + primitiveWrapper?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index a650955d6..5d4388a21 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -212,6 +212,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"), // A module namespace as the frozen null-prototype object typed code sees. unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"), + // Primitive wrapper objects (new String/Number/Boolean, Object(primitive)). + unit((u) => u.opts.primitiveWrapper === true, "scr_js_wrapper.c"), unit( (u) => u.opts.dynAsync || diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 2ce4f21fe..b5005991c 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -105,6 +105,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { brotli: boolean; moduleNamespace: boolean; moduleNamespaceDyn: boolean; + primitiveWrapper: boolean; nativeFetch: boolean; abortSignalOnly: boolean; netEffective: boolean; @@ -316,6 +317,7 @@ export function effectiveRuntimeFeatures( brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, moduleNamespaceDyn: features.moduleNamespaceDyn === true, + primitiveWrapper: features.primitiveWrapper === true, nativeFetch, // The fetch unit has AbortController and AbortSignal; scr_abort.c is // the same implementation without the fetch transport. diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 91fa7d791..295793d8b 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -68,6 +68,7 @@ export interface EarlyExecutableNativeFeatures { nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; + primitiveWrapper?: boolean; zlib: boolean; brotli?: boolean; assert: boolean; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index b837b74fd..52fb2c8ed 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -703,6 +703,7 @@ async function compileExecutableNative( ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), + ...(features.primitiveWrapper ? { primitiveWrapper: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..5078f6f11 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -754,6 +754,15 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "record.throwFrozen", "dyn.toString", "dyn.valueOf", + // Primitive wrapper objects (scr_js_wrapper.c): the conversions run + // user hooks, member reads run getters, and the guard refuses. + "wrapper.new", + "wrapper.toObject", + "wrapper.objectTag", + "wrapper.isStringMethod", + "wrapper.thisString", + "wrapper.guard", + "wrapper.guardList", "string.fromCodePoint", "dyn.defineProps", "dyn.definePrototypeProps", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3287ac54a..eeb986bf6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3682,6 +3682,32 @@ function buildLibFnSignatures() { * names, the compiled getter, and the Symbol.toStringTag key. Draws * Node's frozen null-prototype object, one per module. Never throws. */ "module.namespaceDyn": { argTypes: [STRING, arrayOf(STRING), DYN, DYN], result: DYN }, + /** A primitive wrapper object (scr_js_wrapper_new): the kind + * (0 String, 1 Number, 2 Boolean), the value it converts, and the three + * constructor values for the prototypes' `constructor`. */ + "wrapper.new": { argTypes: [F64, DYN, DYN, DYN, DYN], result: DYN }, + /** ToObject (scr_js_wrapper_to_object): a wrapper for a primitive, the + * value for an object, a fresh object for null/undefined; then the + * three constructor values. */ + "wrapper.toObject": { argTypes: [DYN, DYN, DYN, DYN], result: DYN }, + /** `value instanceof String/Number/Boolean` (scr_js_wrapper_instance_of): + * the kind's prototype on the value's chain. */ + "wrapper.instanceOf": { argTypes: [DYN, F64], result: BOOL }, + /** Object.prototype.toString with the [[PrimitiveValue]] brands + * (scr_js_wrapper_object_tag); dyn.objectTag otherwise. */ + "wrapper.objectTag": { argTypes: [DYN], result: STRING }, + /** util.types brands that read the slot (scr_js_wrapper_type_is). */ + "wrapper.typeIs": { argTypes: [DYN, STRING], result: BOOL }, + /** The value unchanged, or the not-supported Error when its own data + * graph holds a wrapper (scr_js_wrapper_guard; the string names the + * consumer). guardList checks each element. */ + "wrapper.guard": { argTypes: [DYN, STRING], result: DYN }, + "wrapper.guardList": { argTypes: [arrayOf(DYN), STRING], result: arrayOf(DYN) }, + /** Whether the member `name` of the value is the builtin + * String.prototype method (scr_js_wrapper_is_string_method), and the + * method's receiver, ToString of the value (scr_js_wrapper_this_string). */ + "wrapper.isStringMethod": { argTypes: [DYN, STRING], result: BOOL }, + "wrapper.thisString": { argTypes: [DYN, STRING], result: DYN }, "fs.callbackValue": { argTypes: [STRING], result: DYN }, "fs.callbackCall": { argTypes: [STRING, DYN], result: DYN }, "process.hrtimeValue": { argTypes: [], result: DYN }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 28677f3ec..f2fa176c3 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -975,6 +975,11 @@ const RUNTIME_FEATURE_RULES = { "webstream.class", "webstream.instanceOf", "webstream.install", + // scr_js_wrapper.c answers other values' tags through + // scr_dyn_object_tag_of, and its prototype methods call scr_dyn_invoke. + "wrapper.objectTag", + "wrapper.new", + "wrapper.toObject", ], // Constant-key reads of Object.prototype member names answer the // inherited member through scr_dyn_invoke.c. @@ -1217,6 +1222,8 @@ const RUNTIME_FEATURE_RULES = { moduleNamespace: rule({ fns: ["module.namespaceValue"] }), /** A module namespace boxed for typed code (scr_module_namespace_dyn.c). */ moduleNamespaceDyn: rule({ fns: ["module.namespaceDyn"] }), + /** Primitive wrapper objects (scr_js_wrapper.c). */ + primitiveWrapper: rule({ prefixes: ["wrapper."] }), /** The WebAssembly API (scr_wasm.c). */ wasm: rule({ prefixes: ["wasm."] }), /** crypto.subtle importKey/sign/verify/digest and CryptoKey: @@ -1318,6 +1325,7 @@ function noRuntimeFeatures(): RuntimeFeatures { stdioExtended: false, moduleNamespace: false, moduleNamespaceDyn: false, + primitiveWrapper: false, stream: false, streamDyn: false, handleProps: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index b1d03b52d..b123d0bbf 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -36,6 +36,7 @@ const features = { brotli: false, moduleNamespace: false, moduleNamespaceDyn: false, + primitiveWrapper: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 87d42ac5f..5697a0c27 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -100,6 +100,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_module_namespace.c", "moduleNamespace"], // The same namespace as the frozen null-prototype object typed code sees. ["scr_module_namespace_dyn.c", "moduleNamespaceDyn"], + // Primitive wrapper objects. + ["scr_js_wrapper.c", "primitiveWrapper"], // Blob and File box their reads as dyn promises through scr_async_dyn.c. ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index f4bc8db68..d2ee9ccb3 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1447,6 +1447,24 @@ ScrBytes *scr_dyn_bytes_unbox(const ScrDyn *d); bool scr_dyn_bytes_is(const ScrDyn *d, int elem); bool scr_dyn_typed_array_is(const ScrDyn *d, int elem); bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe); + +/* Primitive wrapper objects (scr_js_wrapper.c, linked only into programs + * that create one): `new String/Number/Boolean` (kind 0/1/2) and ToObject, + * with the compiled String, Number and Boolean constructor values; the + * instanceof, Object.prototype.toString and util.types answers that read + * the [[PrimitiveValue]] slot; and the guard of the consumers that cannot + * read it yet. */ +ScrDyn *scr_js_wrapper_new(double kind, ScrDyn *value, ScrDyn *string_ctor, ScrDyn *number_ctor, + ScrDyn *boolean_ctor); +ScrDyn *scr_js_wrapper_to_object(ScrDyn *value, ScrDyn *string_ctor, ScrDyn *number_ctor, + ScrDyn *boolean_ctor); +bool scr_js_wrapper_instance_of(ScrDyn *value, double kind); +ScrStr *scr_js_wrapper_object_tag(ScrDyn *value); +bool scr_js_wrapper_type_is(ScrDyn *value, ScrStr *probe); +ScrDyn *scr_js_wrapper_guard(ScrDyn *value, ScrStr *consumer); +ScrArr *scr_js_wrapper_guard_list(ScrArr *values, ScrStr *consumer); +bool scr_js_wrapper_is_string_method(ScrDyn *value, ScrStr *name); +ScrDyn *scr_js_wrapper_this_string(ScrDyn *value, ScrStr *name); ScrDyn *scr_dyn_util_type_value(const ScrDyn *value, const ScrStr *probe); void scr_dyn_arr_push(ScrDyn *arr, ScrDyn *item); /* Spread completion for a runtime-arity argument list (`f(...xs)` in the diff --git a/packages/runtime/src/scr_js_wrapper.c b/packages/runtime/src/scr_js_wrapper.c new file mode 100644 index 000000000..da218a173 --- /dev/null +++ b/packages/runtime/src/scr_js_wrapper.c @@ -0,0 +1,516 @@ +/* Primitive wrapper objects: `new String(s)`, `new Number(n)`, + * `new Boolean(b)` and ToObject of a primitive (`Object(p)`). + * + * A wrapper is an ordinary dictionary-mode object (SCR_DYN_OBJ). Its + * prototype is this unit's String, Number or Boolean prototype object, and + * its [[PrimitiveValue]] internal slot rides the object's source identity + * (scr_dyn_new_obj_with_identity): a ScrJsWrapperSlot that only this unit's + * accessor recognizes (scr_dyn_obj_source_cast), so no shared structure or + * branch changes and a program that never creates a wrapper links nothing + * of this unit. A String wrapper owns its index properties (non-writable, + * enumerable, non-configurable) and `length` (non-writable, non-enumerable, + * non-configurable), as Node's String exotic objects do. + * + * Every generic dynamic operation already handles that shape: ToPrimitive + * finds valueOf and toString on the prototype, property reads and writes + * honor the attributes, key listing orders the indices first, identity is + * the object's, typeof answers "object" and ToBoolean true. The prototype + * methods read the slot (thisStringValue, thisNumberValue, + * thisBooleanValue) or, for the other String methods, apply ToString to the + * receiver and call the method on the primitive. */ +#include "scr_runtime.h" + +#include +#include +#include +#include + +enum { SCR_JS_WRAPPER_STRING = 0, SCR_JS_WRAPPER_NUMBER = 1, SCR_JS_WRAPPER_BOOLEAN = 2 }; + +static const char *const scr_js_wrapper_names[3] = { "String", "Number", "Boolean" }; + +typedef struct ScrJsWrapperSlot { + size_t rc; + int kind; + ScrDyn *primitive; /* owned */ + ScrDyn *object; /* borrowed: the wrapper that owns this slot */ +} ScrJsWrapperSlot; + +static SCR_TL ScrDyn *scr_js_wrapper_prototypes[3]; +static SCR_TL bool scr_js_wrapper_cleanup_registered; + +static void *scr_js_wrapper_slot_retain(void *p) { + ((ScrJsWrapperSlot *)p)->rc++; + return p; +} + +/* The source-identity accessor: `false` releases one reference (the + * object's release protocol); `true` asks for the object view, which is the + * wrapper itself (+1). Its address is the wrapper brand. */ +static ScrDyn *scr_js_wrapper_slot_access(void *p, bool materialize) { + ScrJsWrapperSlot *slot = p; + if (materialize) return scr_dyn_retain(slot->object); + if (--slot->rc == 0) { + scr_dyn_release(slot->primitive); + free(slot); + } + return NULL; +} + +/* The wrapper slot of `d` (+1 reference), or NULL when `d` is not a + * wrapper. */ +static ScrJsWrapperSlot *scr_js_wrapper_slot_of(const ScrDyn *d) { + return scr_dyn_obj_source_cast(d, scr_js_wrapper_slot_access, scr_js_wrapper_slot_retain); +} + +static void scr_js_wrapper_slot_drop(ScrJsWrapperSlot *slot) { + if (slot) scr_js_wrapper_slot_access(slot, false); +} + +static void scr_js_wrapper_cleanup(void) { + for (int i = 0; i < 3; i++) { + ScrDyn *prototype = scr_js_wrapper_prototypes[i]; + scr_js_wrapper_prototypes[i] = NULL; + scr_dyn_release(prototype); + } +} + +static void scr_js_wrapper_type_error(const char *method, int kind) { + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, scr_js_wrapper_names[kind]); + scr_jb_puts(&text, ".prototype."); + scr_jb_puts(&text, method); + scr_jb_puts(&text, " requires that 'this' be a"); + scr_jb_puts(&text, kind == SCR_JS_WRAPPER_STRING ? " String" : kind == SCR_JS_WRAPPER_NUMBER ? " Number" : " Boolean"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); +} + +/* thisStringValue / thisNumberValue / thisBooleanValue: the primitive of + * `self` (+1) when it is that primitive or a wrapper of that kind, else + * Node's TypeError naming `method` and NULL. */ +static ScrDyn *scr_js_wrapper_this_value(const ScrDyn *self, int kind, const char *method) { + ScrDynKind want = kind == SCR_JS_WRAPPER_STRING ? SCR_DYN_STR + : kind == SCR_JS_WRAPPER_NUMBER ? SCR_DYN_NUM : SCR_DYN_BOOL; + if (self->kind == want) return scr_dyn_retain((ScrDyn *)self); + ScrJsWrapperSlot *slot = scr_js_wrapper_slot_of(self); + if (slot && slot->kind == kind) { + ScrDyn *primitive = scr_dyn_retain(slot->primitive); + scr_js_wrapper_slot_drop(slot); + return primitive; + } + scr_js_wrapper_slot_drop(slot); + scr_js_wrapper_type_error(method, kind); + return NULL; +} + +/* The ToIntegerOrInfinity-style numeric argument `i` (undefined when + * absent); false with the conversion's exception pending. */ +static bool scr_js_wrapper_number_arg(ScrDyn *const *args, size_t argc, size_t i, double *out, + bool *present) { + *present = i < argc && args[i]->kind != SCR_DYN_UNDEF; + *out = 0; + if (!*present) return true; + return scr_dyn_number_coerce_js(args[i], out); +} + +static ScrDyn *scr_js_wrapper_string_result(ScrStr *text) { + if (!text) return NULL; + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; +} + +/* Method `method` of kind `kind` applied to its primitive receiver. + * valueOf and String's toString answer the primitive; Number's methods + * format through the runtime's number formatters; Boolean's toString + * spells the value. The String methods the dynamic dispatch implements + * (at, slice, indexOf, lastIndexOf, includes) run there; any other String + * method called through its function value (`String.prototype.trim.call`, + * a method value stored and called later) refuses, because the compiler + * lowers those calls itself at their call sites. */ +static ScrDyn *scr_js_wrapper_apply(int kind, const char *method, ScrDyn *primitive, + ScrDyn *const *args, size_t argc) { + if (strcmp(method, "valueOf") == 0) return scr_dyn_retain(primitive); + if (kind == SCR_JS_WRAPPER_BOOLEAN) { + const char *text = primitive->v.b ? "true" : "false"; + return scr_js_wrapper_string_result(scr_str_new(text, strlen(text))); + } + if (kind == SCR_JS_WRAPPER_NUMBER) { + double x = primitive->v.num, digits; + bool present; + if (strcmp(method, "toString") == 0) + return scr_js_wrapper_string_result( + scr_num_to_string_radix(x, argc > 0 ? args[0] : scr_dyn_undefined())); + if (!scr_js_wrapper_number_arg(args, argc, 0, &digits, &present)) return NULL; + if (strcmp(method, "toFixed") == 0) { + ScrStr *text = scr_num_to_fixed(x, digits); + if (scr_exc_pending()) { scr_str_release(text); return NULL; } + return scr_js_wrapper_string_result(text); + } + if (strcmp(method, "toPrecision") == 0) { + ScrStr *text = scr_num_to_precision(x, digits, present); + if (scr_exc_pending()) { scr_str_release(text); return NULL; } + return scr_js_wrapper_string_result(text); + } + if (strcmp(method, "toExponential") == 0) { + ScrStr *text = present ? scr_num_to_exponential_digits(x, digits) : scr_num_to_exponential(x); + if (scr_exc_pending()) { scr_str_release(text); return NULL; } + return scr_js_wrapper_string_result(text); + } + } else if (strcmp(method, "toString") == 0) { + return scr_dyn_retain(primitive); + } else if (strcmp(method, "at") == 0 || strcmp(method, "slice") == 0 || + strcmp(method, "indexOf") == 0 || strcmp(method, "lastIndexOf") == 0 || + strcmp(method, "includes") == 0) { + return scr_dyn_invoke(primitive, method, args, argc, method); + } + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, scr_js_wrapper_names[kind]); + scr_jb_puts(&text, ".prototype."); + scr_jb_puts(&text, method); + scr_jb_puts(&text, " called through its function value is not supported yet"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + scr_str_release(message); + return NULL; +} + +/* A prototype method: caps[0] holds the method name (a dyn string) and + * caps[1] the kind (a dyn number). valueOf, and toString of Number and + * Boolean and String, read the slot; Number's other methods run on + * thisNumberValue; String's other methods run on ToString of a receiver + * that is neither null nor undefined. Each then calls the method on the + * primitive through the ordinary dynamic dispatch. */ +static ScrDyn *scr_js_wrapper_method(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + ScrDyn *name = scr_box_get_ref(clo->caps[0]); /* +1 */ + ScrDyn *kind_box = scr_box_get_ref(clo->caps[1]); + int kind = (int)kind_box->v.num; + scr_dyn_release(kind_box); + const char *method = name->v.str->data; + ScrDyn *self = scr_dyn_this_get(); /* +1 */ + ScrDyn *primitive = NULL; + bool slot_method = kind != SCR_JS_WRAPPER_STRING || strcmp(method, "valueOf") == 0 || + strcmp(method, "toString") == 0; + if (slot_method) { + primitive = scr_js_wrapper_this_value(self, kind, method); + } else if (self->kind == SCR_DYN_UNDEF || self->kind == SCR_DYN_NULL) { + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "String.prototype."); + scr_jb_puts(&text, method); + scr_jb_puts(&text, " called on null or undefined"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + } else { + ScrStr *text = scr_dyn_string_coerce_js(self); + if (text) { + primitive = scr_dyn_new_str(text); + scr_str_release(text); + } + } + scr_dyn_release(self); + ScrDyn *result = primitive ? scr_js_wrapper_apply(kind, method, primitive, args, argc) : NULL; + scr_dyn_release(primitive); + scr_dyn_release(name); + return result; +} + +/* Defines `key` on `object` as a non-enumerable, writable, configurable + * data property, the attributes of a builtin prototype member. */ +static void scr_js_wrapper_define_hidden(ScrDyn *object, const char *key, ScrDyn *value) { + size_t len = strlen(key); + scr_dyn_obj_set(object, key, len, value); + scr_dyn_obj_set_attrs_at(object, scr_dyn_obj_index_of(object, key, len), true, false, true); +} + +static ScrDyn *scr_js_wrapper_method_value(const char *name, int kind, uint32_t length) { + ScrClosure *closure = scr_closure_new(NULL, 2); + closure->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + ScrStr *text = scr_str_new(name, strlen(name)); + scr_box_set_ref(closure->caps[0], scr_dyn_new_str(text)); + scr_str_release(text); + closure->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[1], scr_dyn_new_num((double)kind)); + return scr_dyn_new_func(closure, scr_js_wrapper_method, length, "native:wrapper", name); +} + +/* The prototype members, with Node's `length` values. */ +typedef struct { const char *name; uint32_t length; } ScrJsWrapperMember; + +static const ScrJsWrapperMember scr_js_wrapper_string_members[] = { + {"anchor", 1}, {"at", 1}, {"big", 0}, {"blink", 0}, {"bold", 0}, {"charAt", 1}, + {"charCodeAt", 1}, {"codePointAt", 1}, {"concat", 1}, {"endsWith", 1}, {"fixed", 0}, + {"fontcolor", 1}, {"fontsize", 1}, {"includes", 1}, {"indexOf", 1}, {"isWellFormed", 0}, + {"italics", 0}, {"lastIndexOf", 1}, {"link", 1}, {"localeCompare", 1}, {"match", 1}, + {"matchAll", 1}, {"normalize", 0}, {"padEnd", 1}, {"padStart", 1}, {"repeat", 1}, + {"replace", 2}, {"replaceAll", 2}, {"search", 1}, {"slice", 2}, {"small", 0}, + {"split", 2}, {"startsWith", 1}, {"strike", 0}, {"sub", 0}, {"substr", 2}, + {"substring", 2}, {"sup", 0}, {"toLocaleLowerCase", 0}, {"toLocaleUpperCase", 0}, + {"toLowerCase", 0}, {"toString", 0}, {"toUpperCase", 0}, {"toWellFormed", 0}, + {"trim", 0}, {"trimEnd", 0}, {"trimLeft", 0}, {"trimRight", 0}, {"trimStart", 0}, + {"valueOf", 0}, {NULL, 0}, +}; +static const ScrJsWrapperMember scr_js_wrapper_number_members[] = { + {"toExponential", 1}, {"toFixed", 1}, {"toPrecision", 1}, {"toString", 1}, + {"valueOf", 0}, {"toLocaleString", 0}, {NULL, 0}, +}; +static const ScrJsWrapperMember scr_js_wrapper_boolean_members[] = { + {"toString", 0}, {"valueOf", 0}, {NULL, 0}, +}; + +/* The kind's prototype object (borrowed), built on first use with + * `constructor` (the compiled constructor value) and the members above. + * Its own prototype is Object.prototype. */ +static ScrDyn *scr_js_wrapper_prototype(int kind, ScrDyn *constructor) { + if (scr_js_wrapper_prototypes[kind]) return scr_js_wrapper_prototypes[kind]; + if (!scr_js_wrapper_cleanup_registered) { + scr_atexit(scr_js_wrapper_cleanup); + scr_js_wrapper_cleanup_registered = true; + } + ScrDyn *prototype = scr_dyn_new_obj(); + scr_js_wrapper_define_hidden(prototype, "constructor", scr_dyn_retain(constructor)); + const ScrJsWrapperMember *members = kind == SCR_JS_WRAPPER_STRING ? scr_js_wrapper_string_members + : kind == SCR_JS_WRAPPER_NUMBER ? scr_js_wrapper_number_members + : scr_js_wrapper_boolean_members; + for (const ScrJsWrapperMember *m = members; m->name; m++) + scr_js_wrapper_define_hidden(prototype, m->name, scr_js_wrapper_method_value(m->name, kind, m->length)); + scr_js_wrapper_prototypes[kind] = prototype; + return prototype; +} + +/* The wrapper of kind `kind` over `primitive` (borrowed; already the + * converted primitive). +1. */ +static ScrDyn *scr_js_wrapper_make(int kind, ScrDyn *primitive, ScrDyn *const constructors[3]) { + ScrJsWrapperSlot *slot = malloc(sizeof *slot); + if (!slot) scr_trap("scriptc: out of memory\n"); + *slot = (ScrJsWrapperSlot){ 0, kind, scr_dyn_retain(primitive), NULL }; + /* scr_dyn_new_obj_with_identity retains once: the object's reference. */ + ScrDyn *object = scr_dyn_new_obj_with_identity(slot, scr_js_wrapper_slot_retain, scr_js_wrapper_slot_access); + slot->object = object; + object->prototype = scr_dyn_retain(scr_js_wrapper_prototype(kind, constructors[kind])); + if (kind == SCR_JS_WRAPPER_STRING) { + /* Each UTF-16 code unit is an own index property. */ + ScrStr *text = primitive->v.str; + double units = scr_str_utf16_len(text); + for (double i = 0; i < units; i++) { + ScrStr *code_unit = scr_str_char_at(text, i); /* +1 */ + ScrDyn *unit = scr_dyn_new_str(code_unit); + scr_str_release(code_unit); + char key[32]; + int key_len = snprintf(key, sizeof key, "%.0f", i); + scr_dyn_obj_set(object, key, (size_t)key_len, unit); + scr_dyn_obj_set_attrs_at(object, scr_dyn_obj_index_of(object, key, (size_t)key_len), false, true, false); + } + scr_dyn_obj_set(object, "length", 6, scr_dyn_new_num(units)); + scr_dyn_obj_set_attrs_at(object, scr_dyn_obj_index_of(object, "length", 6), false, false, false); + } + return object; +} + +/* `new String(v)` / `new Number(v)` / `new Boolean(v)`: kind 0, 1 or 2; + * `value` is the argument (the undefined value is converted like any + * other: callers pass "", 0 or false for an omitted argument). The + * constructors are the compiled String, Number and Boolean values. +1, or + * NULL with the conversion's exception pending. All arguments borrowed. */ +ScrDyn *scr_js_wrapper_new(double kind_number, ScrDyn *value, ScrDyn *string_ctor, ScrDyn *number_ctor, + ScrDyn *boolean_ctor) { + int kind = (int)kind_number; + ScrDyn *const constructors[3] = { string_ctor, number_ctor, boolean_ctor }; + ScrDyn *primitive = NULL; + if (kind == SCR_JS_WRAPPER_STRING) { + if (value->kind == SCR_DYN_SYMBOL) { + static const char message[] = "Cannot convert a Symbol value to a string"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + ScrStr *text = scr_dyn_string_coerce_js(value); + if (!text) return NULL; + primitive = scr_dyn_new_str(text); + scr_str_release(text); + } else if (kind == SCR_JS_WRAPPER_NUMBER) { + double number = scr_dyn_number_constructor(value); + if (scr_exc_pending()) return NULL; + primitive = scr_dyn_new_num(number); + } else { + primitive = scr_dyn_new_bool(scr_dyn_truthy(value)); + } + ScrDyn *object = scr_js_wrapper_make(kind, primitive, constructors); + scr_dyn_release(primitive); + return object; +} + +/* ToObject: a wrapper for a string, number or boolean; the value itself + * for an object; a fresh ordinary object for null and undefined (the + * Object constructor's answer, not ToObject's TypeError). BigInt and Symbol + * wrappers are refused. +1. */ +ScrDyn *scr_js_wrapper_to_object(ScrDyn *value, ScrDyn *string_ctor, ScrDyn *number_ctor, + ScrDyn *boolean_ctor) { + ScrDyn *const constructors[3] = { string_ctor, number_ctor, boolean_ctor }; + switch (value->kind) { + case SCR_DYN_STR: return scr_js_wrapper_make(SCR_JS_WRAPPER_STRING, value, constructors); + case SCR_DYN_NUM: return scr_js_wrapper_make(SCR_JS_WRAPPER_NUMBER, value, constructors); + case SCR_DYN_BOOL: return scr_js_wrapper_make(SCR_JS_WRAPPER_BOOLEAN, value, constructors); + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: return scr_dyn_new_obj(); + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: { + static const char message[] = "Object() of a bigint or symbol is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; + } + default: return scr_dyn_retain(value); + } +} + +/* `value instanceof String/Number/Boolean`: the kind's prototype on the + * value's prototype chain. A primitive is never an instance; a prototype + * that was never built is on no chain. */ +bool scr_js_wrapper_instance_of(ScrDyn *value, double kind_number) { + ScrDyn *prototype = scr_js_wrapper_prototypes[(int)kind_number]; + if (!prototype || value->kind != SCR_DYN_OBJ) return false; + for (const ScrDyn *current = value->prototype; current; current = current->prototype) + if (current == prototype) return true; + return false; +} + +/* Object.prototype.toString: "[object String]", "[object Number]" or + * "[object Boolean]" for a wrapper without an own or inherited + * Symbol.toStringTag; the ordinary answer otherwise. +1. */ +ScrStr *scr_js_wrapper_object_tag(ScrDyn *value) { + ScrJsWrapperSlot *slot = scr_js_wrapper_slot_of(value); + if (!slot) return scr_dyn_object_tag_of(value); + int kind = slot->kind; + scr_js_wrapper_slot_drop(slot); + ScrStr *ordinary = scr_dyn_object_tag_of(value); + if (!ordinary) return NULL; + /* An explicit @@toStringTag wins over the builtin tag. */ + if (ordinary->len != 15 || memcmp(ordinary->data, "[object Object]", 15) != 0) return ordinary; + scr_str_release(ordinary); + const char *tag = kind == SCR_JS_WRAPPER_STRING ? "[object String]" + : kind == SCR_JS_WRAPPER_NUMBER ? "[object Number]" : "[object Boolean]"; + return scr_str_new(tag, strlen(tag)); +} + +/* util.types.isStringObject/isNumberObject/isBooleanObject/isBoxedPrimitive. */ +bool scr_js_wrapper_type_is(ScrDyn *value, ScrStr *probe) { + ScrJsWrapperSlot *slot = scr_js_wrapper_slot_of(value); + if (!slot) return false; + int kind = slot->kind; + scr_js_wrapper_slot_drop(slot); +#define PROBE(name) (probe->len == sizeof(name) - 1 && memcmp(probe->data, name, sizeof(name) - 1) == 0) + if (PROBE("isBoxedPrimitive")) return true; + if (PROBE("isStringObject")) return kind == SCR_JS_WRAPPER_STRING; + if (PROBE("isNumberObject")) return kind == SCR_JS_WRAPPER_NUMBER; + if (PROBE("isBooleanObject")) return kind == SCR_JS_WRAPPER_BOOLEAN; +#undef PROBE + return false; +} + +/* Whether `value`'s own data graph (array elements, own data properties of + * ordinary objects and arrays; accessors are not called) holds a wrapper. + * `depth` bounds cycles: a graph deeper than the limit is not searched + * further. */ +static bool scr_js_wrapper_found(const ScrDyn *value, int depth) { + if (depth > 64) return false; + if (value->kind == SCR_DYN_OBJ) { + ScrJsWrapperSlot *slot = scr_js_wrapper_slot_of(value); + if (slot) { + scr_js_wrapper_slot_drop(slot); + return true; + } + for (size_t i = 0; i < scr_dyn_obj_count(value); i++) { + ScrDynProp prop = scr_dyn_obj_prop_at(value, i); + if (!prop.accessor && prop.value && scr_js_wrapper_found(prop.value, depth + 1)) return true; + } + return false; + } + if (value->kind == SCR_DYN_ARR) { + for (size_t i = 0; i < value->v.arr.len; i++) + if (value->v.arr.items[i] && scr_js_wrapper_found(value->v.arr.items[i], depth + 1)) return true; + } + return false; +} + +static void scr_js_wrapper_refuse(const ScrStr *consumer) { + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_put_str(&text, consumer); + scr_jb_puts(&text, " of a primitive wrapper object (new String/Number/Boolean or Object(primitive)) is not supported yet"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + scr_str_release(message); +} + +/* The value (+1), or the not-supported Error and NULL when its own data + * graph holds a wrapper: the consumer named by `consumer` cannot read the + * [[PrimitiveValue]] slot yet. */ +ScrDyn *scr_js_wrapper_guard(ScrDyn *value, ScrStr *consumer) { + if (scr_js_wrapper_found(value, 0)) { + scr_js_wrapper_refuse(consumer); + return NULL; + } + return scr_dyn_retain(value); +} + +/* scr_js_wrapper_guard over each element of a list of dynamic values. */ +ScrArr *scr_js_wrapper_guard_list(ScrArr *values, ScrStr *consumer) { + for (size_t i = 0; i < values->len; i++) { + ScrDyn *value = scr_arr_get_ref(values, (double)i); /* +1 */ + bool found = scr_js_wrapper_found(value, 0); + scr_dyn_release(value); + if (found) { + scr_js_wrapper_refuse(consumer); + return NULL; + } + } + return scr_arr_retain(values); +} + +/* Whether `name` on `value` resolves to this unit's String.prototype + * method of that name: the compiler then runs its own lowering of the + * method on ToString of the receiver (scr_js_wrapper_this_string), which + * is what the builtin does. The read runs any getter on the chain, as the + * member read of a call does. */ +bool scr_js_wrapper_is_string_method(ScrDyn *value, ScrStr *name) { + if (value->kind != SCR_DYN_OBJ) return false; + ScrDyn *member = scr_dyn_obj_read(value, name->data, name->len); + if (!member) return false; + bool builtin = false; + if (member->kind == SCR_DYN_FUNC && member->v.fn.thunk == scr_js_wrapper_method) { + ScrDyn *member_name = scr_box_get_ref(member->v.fn.clo->caps[0]); + ScrDyn *kind = scr_box_get_ref(member->v.fn.clo->caps[1]); + builtin = (int)kind->v.num == SCR_JS_WRAPPER_STRING && member_name->v.str->len == name->len && + memcmp(member_name->v.str->data, name->data, name->len) == 0; + scr_dyn_release(kind); + scr_dyn_release(member_name); + } + scr_dyn_release(member); + return builtin; +} + +/* The receiver of a String.prototype method: ToString of a value that is + * neither undefined nor null (the method then sees the primitive). +1, or + * NULL with the TypeError or the conversion's exception pending. */ +ScrDyn *scr_js_wrapper_this_string(ScrDyn *value, ScrStr *name) { + if (value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NULL) { + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "String.prototype."); + scr_jb_put_str(&text, name); + scr_jb_puts(&text, " called on null or undefined"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + return NULL; + } + ScrStr *text = scr_dyn_string_coerce_js(value); + if (!text) return NULL; + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; +} From ad449b53eaa3837e84fee4927fbca6aa00645c90 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:40:56 -0700 Subject: [PATCH 120/193] Compile primitive wrapper objects as ordinary dynamic objects new String/Number/Boolean and Object(primitive) refused in JavaScript. A wrapper is now an ordinary dictionary-mode object whose prototype is the new gated unit's String, Number or Boolean prototype, with the [[PrimitiveValue]] slot on the object's source identity. Coercions, property reads, method calls, key order, identity and typeof reuse the dynamic tier; the prototype methods read the slot. Once a program creates wrappers, the boxed types map to the dynamic value, instanceof tests the prototype chain, Object.prototype.toString and util.types read the slot, and the consumers that cannot read it yet (JSON, inspect, structuredClone, deep equality) refuse a wrapper loudly. Programs without a wrapper site are unchanged. The npm suite's node24-text-utilities fixture builds wrappers, so that case now runs instead of refusing with SC2020; it records the divergence it now shows (the error formatter names a wrapper's class Object, and function values print without their source) instead. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/indexed-objects.ts | 8 + .../lowering/expressions/relational.ts | 18 ++ .../src/frontend/lowering/lower-calls.ts | 94 ++++++- .../src/frontend/lowering/lower-classes.ts | 3 + .../compiler/src/frontend/lowering/lowerer.ts | 6 + .../frontend/lowering/primitive-wrappers.ts | 237 ++++++++++++++++++ packages/compiler/src/frontend/program.ts | 10 +- packages/compiler/src/frontend/tsc-codes.ts | 3 + packages/compiler/src/frontend/type-mapper.ts | 8 + .../corpus/js-primitive-wrappers.js.json | 6 + tests/corpus/js-primitive-wrappers.js | 59 +++++ .../js-primitive-wrappers.js.json | 1 + tests/harness/npm-suite.ts | 11 +- .../primitive-wrapper-refusals.test.ts | 52 ++++ 14 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/primitive-wrappers.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json create mode 100644 tests/corpus/js-primitive-wrappers.js create mode 100644 tests/harness/corpus-costs/js-primitive-wrappers.js.json create mode 100644 tests/harness/primitive-wrapper-refusals.test.ts diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index 92a724b6b..ad8f019b9 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -1,3 +1,4 @@ +import { lowerWrapperToObject, programCreatesWrappers } from "../primitive-wrappers.js"; import { keyedHelperName } from "../stable-names.js"; import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js"; import { countedFor, numLit, varRef } from "../../../ir/build.js"; @@ -50,6 +51,13 @@ export function lowerObjectConstructor( } if (args.length !== 1 || ts.isSpreadElement(args[0]!)) return null; const value = lowerer.lowerExpr(args[0]!); + // ToObject of a value that may be a primitive, once the program creates + // wrapper objects (programCreatesWrappers counts this very call). + if ( + ["dyn", "string", "f64", "bool"].includes(value.type.kind) && + (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)) + ) + return lowerWrapperToObject(lowerer, lowerer.coerceToExpected(value, DYN), loc); if (value.kind === "dynObjLit" || value.kind === "dynArrLit") return value; if ( [ diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index a47e24908..5c41aad52 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1,4 +1,5 @@ /* instanceof and in, including private-name and runtime-key in. */ +import { programCreatesWrappers, WRAPPER_KIND_NUMBER } from "../primitive-wrappers.js"; import { lowerArrayClassInstanceOf } from "../builtins/array-classes.js"; import { dateRootOf } from "../builtins/date-classes.js"; import { keyedHelperName } from "../stable-names.js"; @@ -201,6 +202,23 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc // (typebox's IsBoolean/IsNumber/IsString guards). The operand still runs. if (PRIMITIVE_WRAPPERS.some((name) => lowerer.isStdlibGlobal(expr.right, name))) { const value = lowerer.lowerExpr(expr.left); + // In a program that creates wrappers, a checked-dynamic value may be + // one: the kind's prototype on its chain decides. + const wrapperKind = (["String", "Number", "Boolean"] as const).find((name) => + lowerer.isStdlibGlobal(expr.right, name), + ); + if ( + wrapperKind && + value.type.kind === "dyn" && + (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)) + ) + return { + kind: "libCall", + fn: "wrapper.instanceOf", + args: [value, { kind: "numLit", value: WRAPPER_KIND_NUMBER[wrapperKind], type: F64, loc }], + type: BOOL, + loc, + }; return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: value, loc }], diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e9d79213c..827e69b58 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { programCreatesWrappers } from "./primitive-wrappers.js"; import { arrayClassMemberOwner, arrayClassReceiver, @@ -4045,7 +4046,9 @@ function lowerDynReceiverMethodCall( // else calls its member as Node does. if ( (checkerUntyped || - (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && + (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 || + // A receiver of the boxed String type may be a wrapper object. + (lowerer.createsWrappers ??= programCreatesWrappers(lowerer))) && !call.questionDotToken && !access.questionDotToken && !call.arguments.nodes.some(ts.isSpreadElement) @@ -4289,11 +4292,50 @@ function lowerStringOrMemberCall( const loc = locOf(call); const receiverLocal = lowerer.declareHiddenLocal("%dynCallReceiver", DYN); const receiver = varRef(receiverLocal.id, DYN, loc); - const isString: IrExpr = { kind: "dynTest", test: "string", value: receiver, type: BOOL, loc }; + const nameLit: IrExpr = { kind: "strLit", value: access.name.text, type: STRING, loc }; + const receiverIsString: IrExpr = { + kind: "dynTest", + test: "string", + value: receiver, + type: BOOL, + loc, + }; + // In a program that creates wrapper objects, a receiver whose member is + // the builtin String.prototype method runs the string lowering on + // ToString of the receiver, after the arguments, as the builtin does. + const wrappers = (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)); + const builtinLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallStringMethod", BOOL) : null; + const isString: IrExpr = builtinLocal + ? { kind: "varRef", localId: builtinLocal.id, type: BOOL, loc } + : receiverIsString; + const stringLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallString", DYN) : null; const calleeLocal = lowerer.declareHiddenLocal("%dynCallCallee", DYN); const callee = varRef(calleeLocal.id, DYN, loc); const stmts: IrStmt[] = [ { kind: "varDecl", localId: receiverLocal.id, init: recv, loc }, + ...(builtinLocal + ? [ + { + kind: "varDecl" as const, + localId: builtinLocal.id, + init: { + kind: "logical" as const, + op: "||" as const, + left: receiverIsString, + right: { + kind: "libCall" as const, + fn: "wrapper.isStringMethod" as const, + args: [receiver, nameLit], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + loc, + }, + ] + : []), { kind: "varDecl", localId: calleeLocal.id, @@ -4315,14 +4357,28 @@ function lowerStringOrMemberCall( }, ]; const args = call.arguments.nodes.map((node) => { - const value = lowerer.lowerExpr(node); + const lowered = lowerer.lowerExpr(node); + // A unit-typed argument (`f()` of a void function, a literal undefined + // or null) has no local storage of its own: hold it as the dynamic + // value both branches receive. + const value = + lowered.type.kind === "void" || + lowered.type.kind === "undefinedT" || + lowered.type.kind === "nullT" + ? lowerer.coerceToExpected(lowered, DYN) + : lowered; const local = lowerer.declareHiddenLocal("%dynCallArg", value.type); stmts.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc }); return { node, value: varRef(local.id, value.type, value.loc) }; }); // String.prototype methods ignore arguments past the ones they read. const arity = DYN_STRING_ONLY_ARITY[access.name.text] ?? call.arguments.nodes.length; - const stringReceiver = (): IrExpr => ({ kind: "dynCheck", value: receiver, type: STRING, loc }); + const stringReceiver = (): IrExpr => ({ + kind: "dynCheck", + value: stringLocal ? varRef(stringLocal.id, DYN, loc) : receiver, + type: STRING, + loc, + }); const lowerString = (): IrExpr | null => (call.arguments.nodes.length <= arity ? lowerRegexMethodCall(lowerer, call, access, stringReceiver) @@ -4351,7 +4407,35 @@ function lowerStringOrMemberCall( kind: "ternary", cond: isString, then: stringCall - ? lowerer.coerceToExpected(stringCall, DYN) + ? stringLocal + ? { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: stringLocal.id, + init: { + kind: "ternary", + cond: receiverIsString, + then: receiver, + else_: { + kind: "libCall", + fn: "wrapper.thisString", + args: [receiver, nameLit], + type: DYN, + loc, + }, + type: DYN, + loc, + }, + loc, + }, + ], + result: lowerer.coerceToExpected(stringCall, DYN), + type: DYN, + loc, + } + : lowerer.coerceToExpected(stringCall, DYN) : nodeThrowExpr( 0, "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 014bbc71a..243be7173 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,3 +1,4 @@ +import { lowerWrapperNew } from "./primitive-wrappers.js"; import { lowerAsyncLocalStorageOptions } from "./builtins/async-context.js"; import { shapeOverlayAnnotated } from "../shape-inference/index.js"; import { @@ -10189,6 +10190,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { return lowerWithInlineRequireInit(lowerer, req, () => lowerNew(lowerer, expr)); } } + const wrapper = lowerWrapperNew(lowerer, expr); + if (wrapper) return wrapper; const worker = lowerWorkerNew(lowerer, expr); if (worker) return worker; const readlineUi = lowerReadlineUiNew(lowerer, expr) ?? lowerReadlineInterfaceNew(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..3096fdcdc 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { programCreatesWrappers, routeWrapperConsumers } from "./primitive-wrappers.js"; import type { DiagnosticCode } from "../../diagnostics/codes.js"; import { markInferredFactChecks, shapeOverlayFileNames } from "../shape-inference/index.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; @@ -3485,6 +3486,9 @@ export class Lowerer { * interned here so references under one source-world ABI are the SAME * zero-capture closure (see primitiveCtorClosure). */ readonly primitiveCtorFns = new Map(); + /** Whether a compiled source creates primitive wrapper objects + * (programCreatesWrappers), computed on first use. */ + createsWrappers: boolean | undefined = undefined; /** Builtin-module FUNCTIONS admitted as values by their surface-table * entries. The resolved runtime function plus ABI is the identity key, * so named imports, namespace reads, CommonJS bindings, and alias chains @@ -5134,6 +5138,7 @@ export class Lowerer { mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened), classImplementedProtocol: (type) => this.classImplementedProtocol(type), isStdlibFile: (sf) => this.isStdlibFile(sf), + primitiveWrappers: () => (this.createsWrappers ??= programCreatesWrappers(this)), isNpmFile: (sf) => this.isNpmFile(sf), isExperimentalFile: (sf) => isExperimentalDeclarationFile(sf, this.experimentalFiles), // The declaration-typed boundary's generated types (decl-projection.ts) @@ -9655,6 +9660,7 @@ export class Lowerer { if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); + routeWrapperConsumers(this, functions); trackClassFieldCreation(this, functions); boxClassInstancesExactly(this, functions); addUntypedIntegrityWords(this); diff --git a/packages/compiler/src/frontend/lowering/primitive-wrappers.ts b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts new file mode 100644 index 000000000..d57e398a9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts @@ -0,0 +1,237 @@ +/* Primitive wrapper objects (`new String(s)`, `new Number(n)`, `new + * Boolean(b)`, `Object(primitive)`) in JavaScript sources. A wrapper is an + * ordinary dynamic object (scr_js_wrapper.c): its prototype is the + * wrapper unit's String/Number/Boolean prototype object, whose valueOf, + * toString and methods read the [[PrimitiveValue]] slot, and a String + * wrapper owns its index properties and `length`. Every generic dynamic + * operation (ToPrimitive, property reads, method calls, key listing, + * identity, typeof) already handles that shape; this module builds the + * values and routes the few consumers that must see the slot itself. */ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + F64, + STRING, + type IrExpr, + type IrFunction, + type IrStmt, + type SrcLoc, +} from "../../ir/ir.js"; +import { transformStmtList } from "../../ir/traverse.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import { primitiveCtorClosure } from "./lower-exprs.js"; + +export type WrapperKind = "String" | "Number" | "Boolean"; + +/** The runtime's kind numbers (scr_js_wrapper.c SCR_JS_WRAPPER_*). */ +export const WRAPPER_KIND_NUMBER: Record = { + String: 0, + Number: 1, + Boolean: 2, +}; + +/** Whether any compiled source creates a primitive wrapper: a `new` of the + * stdlib String, Number, Boolean or Object constructor with an argument, or + * a call of the stdlib Object with an argument. Until a program has one, no + * value is a wrapper, so the boxed interface types keep their primitive + * mapping and every fold that relies on it stays. */ +export function programCreatesWrappers(lowerer: Lowerer): boolean { + for (const sf of lowerer.program.getSourceFiles()) { + if (sf.isDeclarationFile || !isJsSourceFile(sf)) continue; + const text = sf.text; + if ( + !text.includes("String(") && + !text.includes("Number(") && + !text.includes("Boolean(") && + !text.includes("Object(") + ) + continue; + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if ( + (ts.isNewExpression(node) || ts.isCallExpression(node)) && + ts.isIdentifier(node.expression) && + (node.arguments?.nodes.length ?? 0) > 0 + ) { + const name = node.expression.text; + const isNew = ts.isNewExpression(node); + if ( + ((isNew && (name === "String" || name === "Number" || name === "Boolean")) || + name === "Object") && + lowerer.isStdlibGlobal(node.expression, name) + ) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + if (found) return true; + } + return false; +} + +/** `new String(x)` / `new Number(x)` / `new Boolean(x)` in a JavaScript + * source: the wrapper over ToString/ToNumber/ToBoolean of the argument + * (the runtime converts, with the object hooks), or over "", 0, false + * without one. Null outside JavaScript sources and for other callees. */ +export function lowerWrapperNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + if (!ts.isIdentifier(expr.expression)) return null; + const kind = expr.expression.text; + if (kind !== "String" && kind !== "Number" && kind !== "Boolean") return null; + if (!lowerer.isStdlibGlobal(expr.expression, kind)) return null; + const args = expr.arguments?.nodes ?? []; + if (args.some(ts.isSpreadElement)) return null; + const loc = locOf(expr); + // Arguments past the first are evaluated and ignored. + const value: IrExpr = + args.length > 0 + ? lowerer.lowerExprExpecting(args[0]!, DYN) + : lowerer.coerceToExpected( + kind === "String" + ? { kind: "strLit", value: "", type: STRING, loc } + : kind === "Number" + ? { kind: "numLit", value: 0, type: F64, loc } + : { kind: "boolLit", value: false, type: BOOL, loc }, + DYN, + ); + const rest = args.slice(1).map((arg) => ({ + kind: "exprStmt" as const, + expr: lowerer.lowerExpr(arg), + loc: locOf(arg), + })); + const created: IrExpr = { + kind: "libCall", + fn: "wrapper.new", + args: [ + { kind: "numLit", value: WRAPPER_KIND_NUMBER[kind], type: F64, loc }, + value, + ...wrapperConstructors(lowerer, loc), + ], + type: DYN, + loc, + }; + if (rest.length === 0) return created; + const local = lowerer.declareHiddenLocal("%wrapperArg", DYN); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }, ...rest], + result: { + ...created, + args: [ + created.args[0]!, + { kind: "varRef", localId: local.id, type: DYN, loc }, + ...created.args.slice(2), + ], + }, + type: DYN, + loc, + }; +} + +/** The String, Number and Boolean constructor values, for the prototypes' + * `constructor` property (the interned closures `x === String` compares). */ +export function wrapperConstructors(lowerer: Lowerer, loc: SrcLoc): IrExpr[] { + return (["String", "Number", "Boolean"] as const).map((name) => + lowerer.coerceToExpected(primitiveCtorClosure(lowerer, name, loc, true), DYN), + ); +} + +/** Consumers that walk a dynamic value's whole graph and must see the + * [[PrimitiveValue]] slot (JSON's unwrapping, util.inspect's `[String: + * 'x']`, structuredClone's wrapper clone, deep equality's primitive + * compare). Until they know the slot, a wrapper reaching one refuses: each + * dynamic argument passes through wrapper.guard, which throws the + * not-supported Error when the value's own data graph holds a wrapper. */ +const GUARDED_CONSUMERS: ReadonlySet = new Set([ + "json.stringifyValue", + "json.stringifyReplacer", + "util.isDeepStrictEqual", + "dyn.structuredClone", + "assert.looseDeepDyn", + "assert.partialDeepDyn", + "console.dirDyn", + "insp.format", + "insp.formatPrimitives", + "insp.formatList", + "insp.formatWithOptions", + "insp.inspect", + "insp.dynS", + "insp.dynFormatS", +]); + +/** util.types brands that read the [[PrimitiveValue]] slot. */ +const WRAPPER_TYPE_PROBES: ReadonlySet = new Set([ + "isStringObject", + "isNumberObject", + "isBooleanObject", + "isBoxedPrimitive", +]); + +/** The consumer as the refusal names it. */ +function consumerName(fn: string): string { + if (fn.startsWith("json.")) return "JSON.stringify"; + if (fn === "dyn.structuredClone") return "structuredClone"; + if (fn === "util.isDeepStrictEqual") return "util.isDeepStrictEqual"; + if (fn.startsWith("assert.")) return "assert's deep equality"; + return "console.log/util.inspect"; +} + +function guarded(arg: IrExpr, fn: string): IrExpr { + const consumer = consumerName(fn); + if (arg.type.kind !== "dyn" && !(arg.type.kind === "array" && arg.type.elem.kind === "dyn")) + return arg; + return { + kind: "libCall", + fn: arg.type.kind === "dyn" ? "wrapper.guard" : "wrapper.guardList", + args: [arg, { kind: "strLit", value: consumer, type: STRING, loc: arg.loc }], + type: arg.type, + loc: arg.loc, + }; +} + +/** In a program that creates wrappers, route the consumers that read + * internal slots: Object.prototype.toString's tag and the util.types + * brands answer from the slot (wrapper.objectTag, wrapper.typeIs), and the + * graph-walking consumers above guard their dynamic arguments. Programs + * without a wrapper site keep every node unchanged. */ +export function routeWrapperConsumers(lowerer: Lowerer, functions: IrFunction[]): void { + if (!(lowerer.createsWrappers ??= programCreatesWrappers(lowerer))) return; + const transform = { + expr: (expr: IrExpr): IrExpr => { + if (expr.kind === "libCall") { + if (expr.fn === "dyn.objectTag") return { ...expr, fn: "wrapper.objectTag" }; + if ( + expr.fn === "util.typeIs" && + expr.args[1]?.kind === "strLit" && + WRAPPER_TYPE_PROBES.has(expr.args[1].value) + ) + return { ...expr, fn: "wrapper.typeIs" }; + if (GUARDED_CONSUMERS.has(expr.fn)) + return { ...expr, args: expr.args.map((arg) => guarded(arg, expr.fn)) }; + } + if ( + expr.kind === "intrinsic" && + (expr.name === "console.log" || expr.name === "console.error") + ) + return { ...expr, args: expr.args.map((arg) => guarded(arg, expr.name)) }; + return expr; + }, + stmt: (stmt: IrStmt): IrStmt => stmt, + }; + for (const fn of functions) fn.body = transformStmtList(fn.body, transform); +} + +/** `Object(x)` / `new Object(x)` over a checked-dynamic `x` in a program + * that creates wrappers: ToObject (wrapper.toObject), a wrapper for a + * primitive, the value itself for an object, a fresh object for null and + * undefined. */ +export function lowerWrapperToObject(lowerer: Lowerer, value: IrExpr, loc: SrcLoc): IrExpr { + const ctors = wrapperConstructors(lowerer, loc); + return { kind: "libCall", fn: "wrapper.toObject", args: [value, ...ctors], type: DYN, loc }; +} diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 7836b8918..5ae7961fd 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1528,8 +1528,16 @@ function suppressedJsStrictness7(d: ts.Diagnostic): boolean { // runtime meaning in a JS file. if (d.code >= 8000 && d.code <= 8999) return true; if (JS_ANY_OPERATOR_CODES.has(d.code)) { - return ts.flattenDiagnosticMessageText(d, "\n").includes("'any'"); + const text = ts.flattenDiagnosticMessageText(d, "\n"); + // A primitive wrapper operand (`new Number(1) * 2`): the operator + // converts it with ToPrimitive, as for any object. + return text.includes("'any'") || /'(Number|String|Boolean)'/.test(text); } + // An index write on a String wrapper (`new String("ab")[5] = "q"`): Node + // adds the property past the end and ignores writes to the read-only + // index properties (sloppy) or throws (strict), as the object does. + if (d.code === 2542 && ts.flattenDiagnosticMessageText(d, "\n").includes("type 'String'")) + return true; // The deprecated Buffer(...) call without `new`, which Node's Buffer // function accepts (containers/bytes.ts lowers it), and fs.ReadStream(...) // and fs.WriteStream(...), which construct their stream diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index a30945be7..f154d1457 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -114,6 +114,9 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // differs (adopted options, hidden declaration twins), and an unused // suppression changes nothing at runtime 2578, + // a primitive on the left of instanceof (`"x" instanceof String`): Node + // answers false, which the lowering folds + 2358, // a lib-floor demand (es2024+ members under the es2023 lib): the member // types as the error-any and its use fences per site with the honest // no-lowering story instead of gating the whole program diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 02539b610..acbe3c76c 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1369,6 +1369,11 @@ export type TypeParamTsResolver = (t: ts.Type) => ts.Type | null; * once per Lowerer and threaded through the recursion — adding a hook means * one new field here, not a new parameter at every call site. */ export interface TypeMapperCtx { + /** Whether the program creates primitive wrapper objects + * (primitive-wrappers.ts): then the boxed interface types (`String`, + * `Number`, `Boolean`) map to the checked-dynamic value instead of the + * primitive. Absent or false: every mapping is unchanged. */ + primitiveWrappers?: () => boolean; /** Node islands (scope-B NI-4): whether a type's values are island * handles, which map to the `foreign` type (a dyn value). Absent in * programs without islands. */ @@ -2408,6 +2413,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ts.symbolName(boxSym) === "Boolean") && boxSym.declarations.some((d) => ctx.isStdlibFile(ts.getSourceFileOfNode(d))) ) { + // Once the program creates wrapper objects, a slot of the boxed type + // may hold one: the checked-dynamic value holds both. + if (ctx.primitiveWrappers?.()) return DYN; return ts.symbolName(boxSym) === "Number" ? F64 : ts.symbolName(boxSym) === "String" diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json new file mode 100644 index 000000000..a26882f25 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-primitive-wrappers.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-primitive-wrappers.js b/tests/corpus/js-primitive-wrappers.js new file mode 100644 index 000000000..35050eadb --- /dev/null +++ b/tests/corpus/js-primitive-wrappers.js @@ -0,0 +1,59 @@ +// Primitive wrapper objects: new String/Number/Boolean and Object(primitive). +// A wrapper is an object (typeof, identity, own properties, prototype +// chain, instanceof, its builtin tag) and converts to its primitive in every +// coercion (ToPrimitive through valueOf/toString, ToNumber, property keys, +// method receivers). ToBoolean of any wrapper is true. + +const s = new String("lego"); +const n = new Number(4.5); +const b = new Boolean(false); +console.log("typeof", typeof s, typeof n, typeof b); +console.log("identity", s === s, new String("a") === new String("a"), s == "lego", s === "lego"); +console.log("valueOf", s.valueOf() === "lego", n.valueOf() + 1, b.valueOf()); +console.log("coerce", s + "!", `${s}`, String(s), n * 2, +n, n + 1, Number(n)); +console.log("truthy", b ? "wrapper true" : "wrapper false", !!b, b == false); +console.log("string reads", s.length, s[1], s.charAt(0), s.toUpperCase(), s.slice(1, 3)); +console.log("number methods", n.toFixed(2), n.toString(), new Number(255).toString(16)); +console.log("instanceof", s instanceof String, n instanceof Number, b instanceof Boolean, s instanceof Object); +console.log("not instances", "x" instanceof String, s instanceof Number); +console.log("tags", Object.prototype.toString.call(s), Object.prototype.toString.call(n), Object.prototype.toString.call(b)); +console.log("constructor", s.constructor === String, n.constructor === Number, b.constructor === Boolean); +console.log("prototype", Object.getPrototypeOf(s) === Object.getPrototypeOf(new String("z"))); + +// Own properties: index properties and length, then expandos. +const w = new String("ab"); +w.extra = 1; +w[5] = "q"; +console.log("keys", Object.keys(w).join(), Object.getOwnPropertyNames(w).join()); +console.log("in", "0" in w, 2 in w, "length" in w, w.hasOwnProperty("length")); +const d = Object.getOwnPropertyDescriptor(w, "0"); +console.log("descriptor", d.value, d.writable, d.enumerable, d.configurable); +const keyed = {}; +keyed[new String("k")] = 1; +keyed[new Number(2)] = 2; +console.log("property keys", Object.keys(keyed).join()); + +// Overridden coercion hooks are honored. +const hooked = new Number(4); +hooked.valueOf = function () { + return 10; +}; +console.log("hooks", hooked + 1, `${hooked}`, String(hooked)); + +// ToObject. +const o = Object("x"); +const p = Object(5); +const q = Object(true); +const r = {}; +console.log("Object()", typeof o, o.length, o == "x", o instanceof String, typeof p, p + 1, q.valueOf()); +console.log("Object() of objects", Object(r) === r, typeof Object(null), typeof Object(undefined)); + +// Brand checks on the prototype methods, read through a wrapper. +const numberValueOf = new Number(0).valueOf; +const stringToString = new String("").toString; +console.log("method values", numberValueOf.call(n), stringToString.call(s), numberValueOf === n.valueOf); +try { + numberValueOf.call(s); +} catch (error) { + console.log("brand", error.constructor.name, error.message); +} diff --git a/tests/harness/corpus-costs/js-primitive-wrappers.js.json b/tests/harness/corpus-costs/js-primitive-wrappers.js.json new file mode 100644 index 000000000..b8ae4c686 --- /dev/null +++ b/tests/harness/corpus-costs/js-primitive-wrappers.js.json @@ -0,0 +1 @@ +{ "plain": 7725, "san": 117318 } diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index d31d96ac9..7d0d7c8b0 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -98,7 +98,16 @@ const KNOWN: Record = { "node24-style-text": { run: "SC2020" }, "node24-system-errors": { run: "SC1090" }, "node24-text-encoding": { run: "SC2020" }, - "node24-text-utilities": { run: "SC2020" }, + // The fixture builds a String and a Number wrapper, which used to refuse + // with SC2020 and now compile: the case runs, but Node's + // ERR_INVALID_ARG_TYPE names a wrapper's class (`an instance of String`) + // where the error formatter prints `Object`, and the program's own + // `String(functionValue)` prints `function () { [native code] }` for a + // named function where Node prints its source. + "node24-text-utilities": { + divergence: + "wrapper objects reach the text utilities now, but ERR_INVALID_ARG_TYPE names them `Object` instead of `String`/`Number`, and function values print without their source", + }, "node24-timers-unref": { run: "SC2020" }, "node24-url-factories": { run: "SC2020" }, "node24-url-mutation": { run: "SC1090" }, diff --git a/tests/harness/primitive-wrapper-refusals.test.ts b/tests/harness/primitive-wrapper-refusals.test.ts new file mode 100644 index 000000000..c6022661c --- /dev/null +++ b/tests/harness/primitive-wrapper-refusals.test.ts @@ -0,0 +1,52 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); + +// JSON, util.inspect, structuredClone and deep equality cannot read a +// wrapper's [[PrimitiveValue]] slot yet, so a wrapper anywhere in their +// argument refuses with a catchable error instead of a silently wrong +// answer. Values without a wrapper keep working in the same program. +test("graph-walking consumers refuse primitive wrapper objects", async () => { + const outDir = mkdtempSync(join(tmpdir(), "scriptc-wrapper-refusals-")); + const entry = join(outDir, "main.mjs"); + writeFileSync( + entry, + ` +function attempt(label, run) { + try { run(); process.stdout.write(label + " ok\\n"); } catch (error) { process.stdout.write(label + " " + error.message + "\\n"); } +} +attempt("json", () => JSON.stringify({ v: new Number(3) })); +attempt("log", () => console.log([new String("a")])); +attempt("clone", () => structuredClone([new Boolean(true)])); +attempt("plain json", () => JSON.stringify({ v: 3 })); +attempt("plain clone", () => structuredClone([true])); +`, + ); + const result = await compile(entry, { + backend: "llvm", + sanitize: process.env["SCRIPTC_SAN"] === "1", + outDir, + outPath: join(outDir, "program"), + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" }); + const refused = (consumer: string): string => + `${consumer} of a primitive wrapper object (new String/Number/Boolean or Object(primitive)) is not supported yet`; + expect(stdout).toBe( + [ + `json ${refused("JSON.stringify")}`, + `log ${refused("console.log/util.inspect")}`, + `clone ${refused("structuredClone")}`, + "plain json ok", + "plain clone ok", + "", + ].join("\n"), + ); +}); From a32db356b6ab67d9b7527eddb6872203b39c9514 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:53:34 -0700 Subject: [PATCH 121/193] See zero-argument wrapper constructions as wrapper sites `new String()`, `new Number()` and `new Boolean()` build a wrapper over "", 0 and false with no argument, but the program fact required an argument, so a program whose only wrapper was one of those compiled the wrapper and kept the primitive mapping for its slot: `s.length` or `s.valueOf()` then failed at runtime with `expected string at $, got object` where Node answers 0 and "". Before this slice that shape was a compile-time refusal, so the fact has to cover it. Count a `new` of the boxed constructors regardless of argument count; `Object()` without an argument is a fresh plain object and keeps requiring one. Adds the zero-argument corpus program, its order-parity baseline and its cost entry. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/primitive-wrappers.ts | 35 ++++++++++--------- .../js-primitive-wrappers-zero-arg.js.json | 6 ++++ .../corpus/js-primitive-wrappers-zero-arg.js | 29 +++++++++++++++ .../js-primitive-wrappers-zero-arg.js.json | 1 + 4 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers-zero-arg.js.json create mode 100644 tests/corpus/js-primitive-wrappers-zero-arg.js create mode 100644 tests/harness/corpus-costs/js-primitive-wrappers-zero-arg.js.json diff --git a/packages/compiler/src/frontend/lowering/primitive-wrappers.ts b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts index d57e398a9..7f9ed51d0 100644 --- a/packages/compiler/src/frontend/lowering/primitive-wrappers.ts +++ b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts @@ -33,19 +33,22 @@ export const WRAPPER_KIND_NUMBER: Record = { }; /** Whether any compiled source creates a primitive wrapper: a `new` of the - * stdlib String, Number, Boolean or Object constructor with an argument, or - * a call of the stdlib Object with an argument. Until a program has one, no - * value is a wrapper, so the boxed interface types keep their primitive - * mapping and every fold that relies on it stays. */ + * stdlib String, Number or Boolean constructor (with or without an + * argument: `new String()` is a wrapper over "", `new Number()` over 0 and + * `new Boolean()` over false), or a call of the stdlib Object with an + * argument. `new Object()` without one is a fresh plain object, so it is + * not a wrapper. Until a program has one, no value is a wrapper, so the + * boxed interface types keep their primitive mapping and every fold that + * relies on it stays. */ export function programCreatesWrappers(lowerer: Lowerer): boolean { for (const sf of lowerer.program.getSourceFiles()) { if (sf.isDeclarationFile || !isJsSourceFile(sf)) continue; const text = sf.text; if ( - !text.includes("String(") && - !text.includes("Number(") && - !text.includes("Boolean(") && - !text.includes("Object(") + !text.includes("String") && + !text.includes("Number") && + !text.includes("Boolean") && + !text.includes("Object") ) continue; let found = false; @@ -53,16 +56,16 @@ export function programCreatesWrappers(lowerer: Lowerer): boolean { if (found) return; if ( (ts.isNewExpression(node) || ts.isCallExpression(node)) && - ts.isIdentifier(node.expression) && - (node.arguments?.nodes.length ?? 0) > 0 + ts.isIdentifier(node.expression) ) { const name = node.expression.text; - const isNew = ts.isNewExpression(node); - if ( - ((isNew && (name === "String" || name === "Number" || name === "Boolean")) || - name === "Object") && - lowerer.isStdlibGlobal(node.expression, name) - ) { + // A `new` of a boxed constructor is a wrapper even without an + // argument; Object is a wrapper site only when it converts a value. + const wrapperNew = + ts.isNewExpression(node) && + (name === "String" || name === "Number" || name === "Boolean"); + const toObject = name === "Object" && (node.arguments?.nodes.length ?? 0) > 0; + if ((wrapperNew || toObject) && lowerer.isStdlibGlobal(node.expression, name)) { found = true; return; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers-zero-arg.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers-zero-arg.js.json new file mode 100644 index 000000000..cbe62f471 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers-zero-arg.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-primitive-wrappers-zero-arg.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-primitive-wrappers-zero-arg.js b/tests/corpus/js-primitive-wrappers-zero-arg.js new file mode 100644 index 000000000..44a15b4bf --- /dev/null +++ b/tests/corpus/js-primitive-wrappers-zero-arg.js @@ -0,0 +1,29 @@ +// Primitive wrapper objects built with no argument: `new String()` is a +// wrapper over "", `new Number()` over 0 and `new Boolean()` over false. +// This program's only wrapper constructions are zero-argument ones, which +// is the case the "program creates wrappers" fact has to see: the values +// are wrappers (typeof, identity, own properties, the prototype chain, the +// builtin tags), and every coercion reads the slot. + +const s = new String(); +const n = new Number(); +const b = new Boolean(); +console.log("typeof", typeof s, typeof n, typeof b); +console.log("values", s.valueOf() === "", n.valueOf() === 0, b.valueOf() === false); +console.log("length", s.length, "0" in s, s.hasOwnProperty("length")); +console.log("keys", Object.keys(s).join(), Object.getOwnPropertyNames(s).join()); +console.log("coerce", s + "!", `${s}`, String(s), n * 2, +n, n + 1, Number(n)); +console.log("truthy", b ? "wrapper true" : "wrapper false", !!b, b == false); +console.log("methods", s.charAt(0) === "", s.toUpperCase(), s.slice(0, 1), n.toFixed(1), n.toString(2)); +console.log("instanceof", s instanceof String, n instanceof Number, b instanceof Boolean, s instanceof Object); +console.log("not instances", "" instanceof String, s instanceof Number); +console.log("tags", Object.prototype.toString.call(s), Object.prototype.toString.call(n), Object.prototype.toString.call(b)); +console.log("constructor", s.constructor === String, n.constructor === Number, b.constructor === Boolean); + +// Own properties: an empty wrapper has none, and expandos land next to them. +const w = new String(); +w.extra = 1; +w[5] = "q"; +console.log("expando", Object.keys(w).join(), w.extra, w[5], w.length); +const length = Object.getOwnPropertyDescriptor(w, "length"); +console.log("descriptor", length.value, length.writable, length.enumerable, length.configurable); diff --git a/tests/harness/corpus-costs/js-primitive-wrappers-zero-arg.js.json b/tests/harness/corpus-costs/js-primitive-wrappers-zero-arg.js.json new file mode 100644 index 000000000..910c75be6 --- /dev/null +++ b/tests/harness/corpus-costs/js-primitive-wrappers-zero-arg.js.json @@ -0,0 +1 @@ +{ "plain": 4826, "san": 7725 } From 7704081eb50b0a5e1ec84be0cd13537d144ce051 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 23:24:43 -0700 Subject: [PATCH 122/193] Avoid call-stack overflow from spread pushes over large compiler lists freezableTypes seeded its worklist with `stack.push(...live)`, one entry per untyped conversion in the program. A spread argument list is capped by the engine's argument limit, so a large program threw `RangeError: Maximum call stack size exceeded` while lowering, which crashed the pi coverage build. Seed the worklist with a loop push instead. The same spread sat on other program-sized accumulators: the diagnostics and runtime-fence ledgers (one entry per deferred statement), the lowered function list, the static program roots, module units and early bindings, and the dropped-package scan results. Those push with loops too. Spreads over a fixed list or over one node's own children stay as they are. The co-located test seeds a 200k-entry live list, which the spread throws on, and pins that a live record type still marks its shape freezable. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 2 +- .../frontend/lowering/frozen-records.test.ts | 58 +++++++++++++++++++ .../src/frontend/lowering/frozen-records.ts | 11 +++- .../src/frontend/lowering/function-returns.ts | 4 +- .../src/frontend/lowering/lower-modules.ts | 4 +- .../src/frontend/lowering/lower-stmts.ts | 9 ++- .../compiler/src/frontend/lowering/lowerer.ts | 25 ++++---- packages/compiler/src/frontend/pipeline.ts | 4 +- packages/compiler/src/frontend/program.ts | 6 +- 9 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/frozen-records.test.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index a3d2a5b11..633de5536 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -2562,7 +2562,7 @@ export function lowerObjectLiteral( ) { throw err; } - lowerer.runtimeFences.push(...lowerer.diags.splice(propDiagsBefore)); + for (const d of lowerer.diags.splice(propDiagsBefore)) lowerer.runtimeFences.push(d); droppedNames.add(name); continue; } diff --git a/packages/compiler/src/frontend/lowering/frozen-records.test.ts b/packages/compiler/src/frontend/lowering/frozen-records.test.ts new file mode 100644 index 000000000..47a9d7c80 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/frozen-records.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; +import { + DYN, + STRING, + VOID, + type IrExpr, + type IrFunction, + type IrStmt, + type SrcLoc, + type IrType, +} from "../../ir/ir.js"; +import { freezableTypes } from "./frozen-records.js"; +import type { Lowerer } from "./lowerer.js"; + +const loc: SrcLoc = { file: "frozen.ts", start: 0, end: 1 }; + +/** An untyped conversion of a `type`-typed value: a live-reference root. */ +function dynFrom(type: IrType, count: number): IrStmt[] { + const expr: IrExpr = { + kind: "dynFrom", + value: { kind: "varRef", localId: "s.0", type, loc }, + type: DYN, + loc, + }; + const body: IrStmt[] = []; + for (let i = 0; i < count; i++) body.push({ kind: "exprStmt", expr, loc }); + return body; +} + +function fn(body: IrStmt[]): IrFunction { + return { name: "f", params: [], returnType: VOID, locals: [], body, loc }; +} + +function stub(shapes: [string, unknown][] = []): Lowerer { + return { + untypedIntegrity: true, + classes: new Map(), + shapes: new Map(shapes), + unions: new Map(), + } as unknown as Lowerer; +} + +describe("freezableTypes", () => { + test("seeds a program-sized live list without a spread argument limit", () => { + // A spread (`stack.push(...live)`) throws RangeError above roughly 100k + // arguments, which a large program's live-reference roots reach. + const roots = 200_000; + const { shapes, arrays } = freezableTypes(stub(), [fn(dynFrom(STRING, roots))]); + expect(shapes.size).toBe(0); + expect(arrays.size).toBe(0); + }); + + test("a live record type makes its shape freezable", () => { + const lowerer = stub([["r0", { id: "r0", fields: [] }]]); + const live = dynFrom({ kind: "record", shapeId: "r0" }, 1); + expect([...freezableTypes(lowerer, [fn(live)]).shapes]).toEqual(["r0"]); + }); +}); diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 75056b3cc..2555dfc2a 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -114,7 +114,7 @@ function holdsAbsentState(e: IrExpr): boolean { /** The record shapes and array types (typeKeys) whose values the program * may freeze. */ -function freezableTypes( +export function freezableTypes( lowerer: Lowerer, functions: IrFunction[], ): { shapes: Set; arrays: Set } { @@ -141,16 +141,21 @@ function freezableTypes( }); if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays }; const stack: IrType[] = []; + // Both seeds are program-sized (one entry per untyped conversion or check in + // every function), so they are pushed with loops: a spread argument list + // exceeds the engine's limit and throws RangeError on a large program. // A record checked from an untyped value copies it, frozen when the value // is (a fresh literal frozen in a JavaScript file included), so the shapes // a check builds are freezable in a program that freezes untyped values. - if (freezesUntyped) stack.push(...checked); + if (freezesUntyped) { + for (const type of checked) stack.push(type); + } if (lowerer.untypedIntegrity) { // An untyped reference to a class instance, or to a record or array // boxed live, reaches the values its fields and elements hold as further // live references. Class instances also cross into untyped code through // compiled callbacks, so every class is a root. - stack.push(...live); + for (const type of live) stack.push(type); for (const info of lowerer.classes.values()) for (const field of info.def.fields) stack.push(field.type); } diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 7378f26a7..0c09999c7 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -578,8 +578,8 @@ export function resolveInferredReturn( // per-statement deferral, applied to one return of an instance). const captured = lowerer.diags.splice(diagsBefore); const ice = captured.filter((d) => d.code === "SC9001"); - if (ice.length > 0) lowerer.diags.push(...ice); - lowerer.runtimeFences.push(...captured.filter((d) => d.code !== "SC9001")); + for (const d of ice) lowerer.diags.push(d); + for (const d of captured) if (d.code !== "SC9001") lowerer.runtimeFences.push(d); const first = captured.find((d) => d.code !== "SC9001"); // Property writes, not literal element writes: the class method // census reads `x["kind"] = …` as a possible write to every method diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index f82728c6e..00299ce58 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1189,8 +1189,8 @@ function deferJsCollectionDiags(lowerer: Lowerer, sf: ts.SourceFile, diagsBefore if (!isJsSourceFile(sf)) return; const captured = lowerer.diags.splice(diagsBefore); const ice = captured.filter((d) => d.code === "SC9001"); - if (ice.length > 0) lowerer.diags.push(...ice); - lowerer.runtimeFences.push(...captured.filter((d) => d.code !== "SC9001")); + for (const d of ice) lowerer.diags.push(d); + for (const d of captured) if (d.code !== "SC9001") lowerer.runtimeFences.push(d); } /** A scalar-literal expression a single-value `module.exports =` can carry diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 377b08917..9dfa5bd0e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -961,8 +961,8 @@ function lowerOneStatement( if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) { const flushed = lowerer.diags.splice(diagsBefore); const ice = flushed.filter((d) => d.code === "SC9001"); - lowerer.diags.push(...ice); - lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001")); + for (const d of ice) lowerer.diags.push(d); + for (const d of flushed) if (d.code !== "SC9001") lowerer.runtimeFences.push(d); } } @@ -1222,7 +1222,10 @@ export function predeclareForwardFnDecl(lowerer: Lowerer, symbol: ts.Symbol): bo entry.out.push(lowerer.lowerNestedFunctionDecl(decl)); } catch (e) { entry.ctx.hoistedFnDecls.delete(decl); - if (previousSink !== null) previousSink.push(...lowerer.diags.splice(diagsBefore)); + if (previousSink !== null) { + const flushed = lowerer.diags.splice(diagsBefore); + for (const d of flushed) previousSink.push(d); + } throw e; } finally { lowerer.diagSink = previousSink; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..8c233bd6f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -9624,9 +9624,9 @@ export class Lowerer { // array-HOF loop functions, which ride the same list), and the // implicit-any instances lowered eagerly at their first call sites. this.settleSharedHelperLocations([...functions, ...this.liftedFns, ...this.implicitFns]); - functions.push(...this.liftedFns); - functions.push(...this.implicitFns); - functions.push(...runtimeClassConstructs(this, functions)); + for (const fn of this.liftedFns) functions.push(fn); + for (const fn of this.implicitFns) functions.push(fn); + for (const fn of runtimeClassConstructs(this, functions)) functions.push(fn); if (this.remainder) { // Deferred collection diagnostics nothing flushed — declarations no @@ -9912,9 +9912,11 @@ export class Lowerer { private reportBlockedBody(decl: ts.FunctionDeclaration): void { const diags = blockedBodyDiagnostics(this, decl); const symbol = declSymbolOf(this, decl); - if (symbol && this.alreadyFlushed.has(symbol)) - this.reachedBlockedBodyDiagnostics.push(...diags); - else for (const d of diags) this.pushDiag(d); + if (symbol && this.alreadyFlushed.has(symbol)) { + for (const d of diags) this.reachedBlockedBodyDiagnostics.push(d); + } else { + for (const d of diags) this.pushDiag(d); + } } /** Whether run() counts a signature-blocked declaration in @@ -10525,7 +10527,7 @@ export class Lowerer { ...byName(this.liftedFns), ...byName(this.implicitFns), ]; - functions.push(...runtimeClassConstructs(this, functions)); + for (const fn of runtimeClassConstructs(this, functions)) functions.push(fn); if (this.regexGroupResults) for (const fn of functions) foldRegexSpanReads(this, fn); // The worklist can leave a virtual site with a single implementation. // Devirtualize after construction/override reachability has settled. @@ -10730,10 +10732,10 @@ export class Lowerer { } const captured = this.diags.splice(diagsBefore); if (captured.some((d) => d.code === "SC9001")) { - this.diags.push(...captured); + for (const d of captured) this.diags.push(d); return null; } - this.runtimeFences.push(...captured); + for (const d of captured) this.runtimeFences.push(d); const fence = this.runtimeFenceStmt(captured[0], node, target.fallback, target.bareMessage); const loc = locOf(node); @@ -11022,7 +11024,7 @@ export class Lowerer { .slice(diagsBefore) .filter((d) => d.rejects !== undefined || this.loweredOnceDiags.has(d)); this.diags.length = diagsBefore; - this.diags.push(...kept); + for (const d of kept) this.diags.push(d); } /** A unit that still fails after its retries commits its first @@ -11035,7 +11037,8 @@ export class Lowerer { d.rejects !== undefined || this.loweredOnceDiags.has(d); const kept = this.diags.slice(diagsBefore).filter(carried); this.diags.length = diagsBefore; - this.diags.push(...first.filter((d) => !carried(d)), ...kept); + for (const d of first) if (!carried(d)) this.diags.push(d); + for (const d of kept) this.diags.push(d); } /** Lowers one unit whose body-level poison skips it (a generic diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 25af03106..28133abd5 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -455,8 +455,8 @@ function scanDroppedPackages( return at !== null && wanted.has(at.name); }); const scanned = scanFiles(load.program, files, { userJs: false, dropped: true, edges: true }); - into.modules.push(...scanned.modules); - into.edges.push(...scanned.edges); + for (const module of scanned.modules) into.modules.push(module); + for (const edge of scanned.edges) into.edges.push(edge); } catch { /* report-only detection */ } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 7836b8918..a96371d1c 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -976,7 +976,7 @@ function loadProgram7( ); if (extraRoots.length === 0) break; program.dispose(); - programRoots.push(...extraRoots); + for (const root of extraRoots) programRoots.push(root); program = host.createProgram([...programRoots, overridesDtsPath()], options); if (pass === 31) throw new Error("static program-root discovery did not converge"); } @@ -4625,7 +4625,7 @@ function cycleReachedDeferredReferences7( if (symbol === undefined) continue; const units = unitsOfName.get(name)!; unitsBySymbol.set(symbol, [...(unitsBySymbol.get(symbol) ?? []), ...units]); - allUnits.push(...units); + for (const unit of units) allUnits.push(unit); } // A named class expression's inner name (`class Impl { static x = // Impl.m(); }`) names the class inside its own body, as the binding it @@ -4794,7 +4794,7 @@ export function moduleEarlyBindings( const out: CycleEarlyBinding[] = []; for (const sf of order) { if (sf.isDeclarationFile || exact.has(sf)) continue; - out.push(...moduleEarlyBindingsOf7(program, sf, order)); + for (const binding of moduleEarlyBindingsOf7(program, sf, order)) out.push(binding); } program.analysis.moduleEarlyBindings = { key, bindings: out }; return out; From 84fcdb452b2e1c960444016762056f89c29c9aa9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:10:08 -0700 Subject: [PATCH 123/193] Plan partial record views for a subset source A record literal asserted to a wider record type (`{ truncated: true } as TruncationResult`) is a checked cast whose source shape lacks some of the target's fields. Slice C1 of the presence-narrowing design: teach the record-view plan, the LLVM backend and the runtime about the PARTIAL view such a cast answers. - `dynCheck.partialView` marks the cast; the validator requires a `dynFrom{liveRef}` record operand whose every field is a target field with an equal type. - `planRecordViews` records the (target, source) pairs it cannot plan as full views and exposes `partial`, `partialTargets` and `partialMissing`; the cycle analysis owns these views like the rest. - `recordViewCall` emits the `SIZE_MAX` sentinel for a target field the source does not declare. - `scr_record_view_slot` answers NULL for a field the viewed record does not declare (its offset table holds `SIZE_MAX`), and `scr_record_view_field` passes that answer through. The emitted view guard, the clone helper and a presence test (`recordHas`) ask for that slot and stop with `scr_trap` and the program's own message when it is absent, so the runtime gains no string constant and no function: a new function in a linked unit shifts the pack for every program. A program without a partial assertion compiles to the same bytes. - `recordHas` asks the view when the field is missing from some partial source, and still honors an undefined-armed field. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/dyn.ts | 7 +- packages/compiler/src/backend/llvm/emitter.ts | 2 + .../src/backend/llvm/expr-primitives.ts | 67 +++++++- .../src/backend/llvm/record-views.test.ts | 100 +++++++++++- .../compiler/src/backend/llvm/record-views.ts | 144 ++++++++++++++++-- .../compiler/src/backend/llvm/shapes.test.ts | 4 + packages/compiler/src/backend/llvm/shapes.ts | 23 ++- packages/compiler/src/backend/llvm/walkers.ts | 2 + packages/compiler/src/ir/fields.generated.ts | 3 +- packages/compiler/src/ir/ir.ts | 8 + packages/compiler/src/ir/validate.ts | 31 ++++ packages/runtime/src/scr_dyn.h | 4 + packages/runtime/src/scr_json.c | 11 +- 13 files changed, 380 insertions(+), 26 deletions(-) diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index dfeffdecb..b5ca61f42 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1179,8 +1179,11 @@ export class LlDyn { const struct = mangleRecordStruct(sourceId); const base = host.nullableUnions.fieldBase(sourceId); const offsets = target.fields.map((field) => { - const index = source.fields.findIndex((f) => f.name === field.name) + base; - return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index}) to ${S})`; + const index = source.fields.findIndex((f) => f.name === field.name); + // A partial view's target field the source does not declare has + // no slot: all ones (SIZE_MAX) marks it absent (record-views.ts). + if (index < 0) return `${S} -1`; + return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index + base}) to ${S})`; }); const offsetTable = `${table}_${k}`; this.defs.push( diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6980c8f45..14932f2b0 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5060,6 +5060,8 @@ export class LlEmitter { idx, (decl) => this.declare(decl), this.rcMode === undefined, + (text) => this.cstr(text), + this.shapeHost.recordViews?.partialTargets.has(shapeId) === true, ); } else { p = this.B.tmp(); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 7599a65de..44db91185 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -10,6 +10,7 @@ import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js"; import { isRefCounted, type IrExpr, type IrStrCmpOp, type IrType } from "../../ir/ir.js"; import { mangleRecordClone, mangleRecordNew } from "../mangle.js"; import { arrNewCall, elemAccess } from "./shapes.js"; +import { recordViewTestLines } from "./record-views.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { offsetIndex } from "./expr-bytes.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; @@ -1473,16 +1474,68 @@ export function emitRecordExpr( return host.emitRecordKeyGet(e); case "recordHas": { // Own-property presence: only an undefined-armed union slot can - // lack its property, by holding the ABSENT state. + // lack its property, by holding the ABSENT state, and a PARTIAL + // record view (record-views.ts) can lack a field its viewed record + // does not declare. + const fieldType = host.recordsById + .get(e.shapeId) + ?.fields.find((f) => f.name === e.field)?.type; const obj = host.emitReadReceiver(e.obj); + if (host.shapeHost.recordViews?.partialMissing(e.shapeId, e.field) !== true) { + const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field); + if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0) + return { name: "true", type: e.type }; + const v = host.loadField(ptr, type, inline); + const absent = host.fieldAbsentTest(v, type.unionId); + const t = B.tmp(); + B.line(`${t} = xor i1 ${absent}, true`); + return { name: t, type: e.type }; + } + // A field some partial view of this shape lacks: ask the view first. + // Only a present field's slot may be looked up — a partial view's + // absent field answers no slot at all (scr_record_view_slot). + const position = host.recordsById.get(e.shapeId)!.fields.findIndex((f) => f.name === e.field); + host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`); + const isView = B.tmp(); + const rhFrom = B.currentLabel(); + const view = B.newLabel("rh.view"); + const done = B.newLabel("rh.done"); + for (const line of recordViewTestLines(host.sizeType, obj.name, isView)) + B.line(line.trimStart()); + // Not a view: every field of the target has its slot. + B.condBr(isView, view, done, `, !prof !{!"branch_weights", i32 1, i32 1000}`); + B.startBlock(view); + const slot = B.tmp(); + B.line( + `${slot} = call ptr @scr_record_view_slot(ptr ${obj.name}, ${host.sizeType} ${position})`, + ); + const hasSlot = B.tmp(); + B.line(`${hasSlot} = icmp ne ptr ${slot}, null`); + B.br(done); + B.startBlock(done); + const hasView = B.tmp(); + B.line(`${hasView} = phi i1 [ true, %${rhFrom} ], [ ${hasSlot}, %${view} ]`); + if (fieldType === undefined || fieldType.kind !== "union") + return { name: hasView, type: e.type }; + const unionId = fieldType.unionId; + if (undefinedArmTag(fieldType, host.unionsById) < 0) return { name: hasView, type: e.type }; + // The field is also undefined-armed: a present slot still answers by + // its ABSENT state, a field the view lacks by the view. + const check = B.newLabel("rh.present"); + const join = B.newLabel("rh.join"); + const from = B.currentLabel(); + B.condBr(hasView, check, join); + B.startBlock(check); const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field); - if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0) - return { name: "true", type: e.type }; const v = host.loadField(ptr, type, inline); - const absent = host.fieldAbsentTest(v, type.unionId); - const t = B.tmp(); - B.line(`${t} = xor i1 ${absent}, true`); - return { name: t, type: e.type }; + const absent = host.fieldAbsentTest(v, unionId); + const present = B.tmp(); + B.line(`${present} = xor i1 ${absent}, true`); + B.br(join); + B.startBlock(join); + const answer = B.tmp(); + B.line(`${answer} = phi i1 [ false, %${from} ], [ ${present}, %${check} ]`); + return { name: answer, type: e.type }; } case "fieldAbsent": return { name: host.absentInstanceRef(e.unionId), type: e.type }; diff --git a/packages/compiler/src/backend/llvm/record-views.test.ts b/packages/compiler/src/backend/llvm/record-views.test.ts index 02a82d873..0cfec7b1e 100644 --- a/packages/compiler/src/backend/llvm/record-views.test.ts +++ b/packages/compiler/src/backend/llvm/record-views.test.ts @@ -3,8 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; import { compile, deserializeModule } from "../../index.js"; -import type { IrModule } from "../../ir/ir.js"; +import { DYN } from "../../ir/ir.js"; +import type { IrExpr, IrModule } from "../../ir/ir.js"; import { computeTraced } from "../cycle-analysis.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import { emitLlvmModule } from "./emitter.js"; import { planRecordViews } from "./record-views.js"; /** The views planned for `source`'s module, as target field names → source @@ -128,3 +131,98 @@ ${conversions} Object.freeze(wide);`); expect(views).toEqual(new Map()); }); + +/** `source`'s module shaped the way the frontend lowers a partial + * assertion (C2): the checked cast of the source record literal carries + * `partialView` over a `dynFrom{liveRef}` of that literal. */ +async function partialModule( + source: string, + sourceFields: string, + targetFields: string, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-record-views-")); + try { + const entry = join(dir, "main.ts"), + outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const mod: IrModule = deserializeModule(await readFile(outPath, "utf8")); + const idOf = (fields: string): string => + (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id; + const exprs: IrExpr[] = []; + for (const fn of mod.functions) + everyStmtList(fn.body, { + stmt: () => true, + expr: (e) => { + exprs.push(e); + return true; + }, + }); + const lit = exprs.find( + (e) => + e.kind === "recordLit" && e.type.kind === "record" && e.type.shapeId === idOf(sourceFields), + ); + const check = exprs.find( + (e) => + e.kind === "dynCheck" && e.type.kind === "record" && e.type.shapeId === idOf(targetFields), + ); + if (!lit || !check || check.kind !== "dynCheck") throw new Error("shapes not found"); + check.partialView = true; + check.value = { kind: "dynFrom", liveRef: true, value: lit, type: DYN, loc: check.loc }; + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a partial assertion plans a partial view with an absent-field sentinel", async () => { + const mod = await partialModule( + ` +type Wide = { a: number; b: string }; +type Narrow = { a: number; z: boolean }; +const wide: Wide = { a: 1, b: "x" }; +const o: unknown = wide; +const v = o as Narrow; +console.log(v.a); +`, + "a,b", + "a,z", + ); + const idOf = (fields: string): string => + (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id; + const target = idOf("a,z"); + const source = idOf("a,b"); + const views = planRecordViews(mod, { traced: () => false, excluded: () => false }); + expect(views.targets.has(target)).toBe(true); + expect(views.sources(target)).toEqual([source]); + expect(views.partial(target, source)).toBe(true); + expect(views.partialTargets.has(target)).toBe(true); + expect(views.partialMissing(target, "z")).toBe(true); + expect(views.partialMissing(target, "a")).toBe(false); + // The offset table marks the target field the source does not declare + // with the all-ones sentinel. + const llvm = emitLlvmModule(mod); + expect(llvm).toMatch(/@sc_rv_\d+_0 = internal constant \[2 x i64\] \[i64 ptrtoint .*, i64 -1\]/); +}); + +test("a full source keeps an ordinary view, not a partial one", async () => { + const mod = await partialModule( + ` +type Wide = { a: number; b: string }; +type Narrow = { a: number }; +const wide: Wide = { a: 1, b: "x" }; +const o: unknown = wide; +const v = o as Narrow; +console.log(v.a); +`, + "a,b", + "a", + ); + const idOf = (fields: string): string => + (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id; + const views = planRecordViews(mod, { traced: () => false, excluded: () => false }); + expect(views.sources(idOf("a"))).toEqual([idOf("a,b")]); + expect(views.partial(idOf("a"), idOf("a,b"))).toBe(false); + expect(views.partialTargets.size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 5574d1dde..f505556b7 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -15,7 +15,15 @@ * checked conversion can produce (the closure of dynCheck targets), and * some other record type whose values reach untyped code as live capsules * (the closure of live dynFrom types) has all of B's fields. A program - * without checked conversions has none, so its code is unchanged. */ + * without checked conversions has none, so its code is unchanged. + * + * A PARTIAL view (`dynCheck.partialView`) pairs a record literal's shape A + * with the wider record B it was asserted to, though A lacks some of B's + * fields. The view is a B value whose record-source entry names A, with + * the sentinel SIZE_MAX for every B field A does not declare: a slot lookup + * answers NULL there (scr_record_view_slot), so a presence test answers false + * and an address lookup stops the program. The frontend's checked reads of + * those fields throw their TypeError first. */ import { isDirentShape, KEY_ORDER_FIELD, @@ -34,9 +42,28 @@ export interface RecordViews { readonly targets: ReadonlySet; /** The record shapes whose capsules convert to a view of `target`. */ sources(target: string): readonly string[]; + /** Whether `target` may be a PARTIAL view of `source`: some of its + * fields are absent, because `source` does not declare them (a partial + * record literal asserted to a wider type). */ + partial(target: string, source: string): boolean; + /** Shape ids a partial record assertion may produce a view of. */ + readonly partialTargets: ReadonlySet; + /** Whether some partial source of view target `target` lacks `field`. */ + partialMissing(target: string, field: string): boolean; } -export const NO_RECORD_VIEWS: RecordViews = { targets: new Set(), sources: () => [] }; +/** The trap of an internal reader of a field a partial view's viewed record + * lacks (a width copy, say): a source-level read of the field threw first. */ +export const PARTIAL_VIEW_ABSENT = + "scriptc: a value asserted as a wider record has no property at this field (copy it explicitly)\n"; + +export const NO_RECORD_VIEWS: RecordViews = { + targets: new Set(), + sources: () => [], + partial: () => false, + partialTargets: new Set(), + partialMissing: () => false, +}; interface ViewOptions { readonly traced: (shapeId: string) => boolean; @@ -155,16 +182,30 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView }; const checked: IrType[] = []; const boxed: IrType[] = []; + // (target B, source A) of a partial record assertion's checked cast. + const partialPairs: { target: string; source: string }[] = []; for (const fn of mod.functions) everyStmtList(fn.body, { stmt: () => true, expr: (e) => { - if (e.kind === "dynCheck") checked.push(e.type); - else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type); + if (e.kind === "dynCheck") { + checked.push(e.type); + if ( + e.partialView === true && + e.type.kind === "record" && + e.value.kind === "dynFrom" && + e.value.value.type.kind === "record" + ) + partialPairs.push({ + target: e.type.shapeId, + source: e.value.value.type.shapeId, + }); + } else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type); return true; }, }); - if (checked.length === 0 || boxed.length === 0) return NO_RECORD_VIEWS; + if ((checked.length === 0 || boxed.length === 0) && partialPairs.length === 0) + return NO_RECORD_VIEWS; const reachable = closure(checked); const live = closure(boxed); if (reachable.size === 0) return NO_RECORD_VIEWS; @@ -182,6 +223,24 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView } if (found.length > 0) sources.set(id, found); } + // Partial pairs join the plan before the cycle analysis, which owns their + // view's capsule reference like any other. A pair whose source has every + // target field is an ordinary full view and is already planned above. + const partials = new Map>(); + for (const pair of partialPairs) { + const target = shapes.get(pair.target); + const source = shapes.get(pair.source); + if (!target || !source || !plainShape(target) || !plainShape(source)) continue; + if (target.freezable === true || source.freezable === true) continue; + if (options.traced(pair.target) || options.excluded(pair.target)) continue; + if (covers(source, target)) continue; + const list = sources.get(pair.target) ?? []; + if (!list.includes(pair.source)) list.push(pair.source); + sources.set(pair.target, list); + const set = partials.get(pair.target); + if (set) set.add(pair.source); + else partials.set(pair.target, new Set([pair.source])); + } // A view's record-source entry owns the source's capsule, an edge the // cycle analysis does not see. A view stays off every cycle only when its // source cannot hold a value of the target type, such as the view itself, @@ -199,11 +258,34 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView changed = true; if (kept.length > 0) sources.set(id, kept); else sources.delete(id); + const partial = partials.get(id); + if (!partial) continue; + for (const source of [...partial]) if (!kept.includes(source)) partial.delete(source); + if (partial.size === 0) partials.delete(id); } } if (sources.size === 0) return NO_RECORD_VIEWS; const targets = new Set(sources.keys()); - return { targets, sources: (target) => sources.get(target) ?? [] }; + // The target fields some partial source lacks: their presence test is a + // runtime view probe, and their slot lookup traps. + const missing = new Map>(); + for (const [id, list] of partials) { + const absent = new Set(); + const target = shapes.get(id); + for (const source of list) { + const declared = new Set((shapes.get(source)?.fields ?? []).map((field) => field.name)); + for (const field of target?.fields ?? []) + if (!declared.has(field.name)) absent.add(field.name); + } + missing.set(id, absent); + } + return { + targets, + sources: (target) => sources.get(target) ?? [], + partial: (target, source) => partials.get(target)?.has(source) === true, + partialTargets: new Set(partials.keys()), + partialMissing: (target, field) => missing.get(target)?.has(field) === true, + }; } /** Lines setting `dest` (i1) to whether the record `obj` of a view target @@ -228,7 +310,12 @@ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest: * `obj` of view-target shape `struct`: the slot itself, or the viewed * record's slot when `obj` is a view. Optimized builds test the mark * inline and look the slot up out of line; unoptimized builds, which give - * every value a frame slot, make the address one call. */ + * every value a frame slot, make the address one call. + * + * `partial` names a shape a PARTIAL view may present: its viewed record can + * lack the field, and the slot lookup answers NULL there (scr_record_view_slot), + * so the address stops with `scr_trap` and `cstr(PARTIAL_VIEW_ABSENT)`. The + * message is the program's own constant; the runtime carries none. */ export function recordViewFieldPtr( B: BlockBuilder, sizeType: "i32" | "i64", @@ -238,13 +325,25 @@ export function recordViewFieldPtr( position: number, declare: (decl: string) => void, outOfLine: boolean, + cstr: (text: string) => string, + partial = false, ): string { if (outOfLine) { - declare(`declare ptr @scr_record_view_field(ptr, ${sizeType}, ${sizeType})`); + const field = "scr_record_view_field"; + declare(`declare ptr @${field}(ptr, ${sizeType}, ${sizeType})`); const ptr = B.tmp(); B.line( - `${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`, + `${ptr} = call ptr @${field}(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`, ); + if (!partial) return ptr; + const absent = B.tmp(); + B.line(`${absent} = icmp eq ptr ${ptr}, null`); + const trap = B.newLabel("rv.absent"); + const join = B.newLabel("rv.present"); + B.condBr(absent, trap, join, `, !prof !{!"branch_weights", i32 1, i32 1000}`); + B.startBlock(trap); + absentTrapLines(B, cstr, declare); + B.startBlock(join); return ptr; } const own = B.tmp(); @@ -260,9 +359,34 @@ export function recordViewFieldPtr( declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType})`); const viewed = B.tmp(); B.line(`${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ${position})`); + let viewFrom = view; + if (partial) { + const absent = B.tmp(); + B.line(`${absent} = icmp eq ptr ${viewed}, null`); + const trap = B.newLabel("rv.absent"); + const present = B.newLabel("rv.present"); + B.condBr(absent, trap, present); + B.startBlock(trap); + absentTrapLines(B, cstr, declare); + viewFrom = present; + B.startBlock(present); + } B.br(join); B.startBlock(join); const ptr = B.tmp(); - B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${view} ]`); + B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${viewFrom} ]`); return ptr; } + +/** The block of an address lookup of a field a partial view lacks: the + * program stops with its own message (PARTIAL_VIEW_ABSENT) and does not + * return. */ +export function absentTrapLines( + B: BlockBuilder, + cstr: (text: string) => string, + declare: (decl: string) => void, +): void { + declare(`declare void @scr_trap(ptr)`); + B.line(`call void @scr_trap(ptr ${cstr(PARTIAL_VIEW_ABSENT)})`); + B.terminate("unreachable"); +} diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts index 4503b5437..3b8e50dba 100644 --- a/packages/compiler/src/backend/llvm/shapes.test.ts +++ b/packages/compiler/src/backend/llvm/shapes.test.ts @@ -28,6 +28,10 @@ function declarationHost( declare(decl) { declarations.push(decl); }, + cstr(text) { + declarations.push(`@cstr ${text}`); + return "@sc_cstr"; + }, needOom() {}, mapOps: (operands) => (operands.every((op) => op === "null") ? "null" : "@sc_map_ops"), sizeType: pointerBits === 32 ? "i32" : "i64", diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 87983c8b9..fc179f4d6 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -73,7 +73,7 @@ import { disposeFieldLines, plainRefType, } from "./arenas.js"; -import { recordViewTestLines, type RecordViews } from "./record-views.js"; +import { PARTIAL_VIEW_ABSENT, recordViewTestLines, type RecordViews } from "./record-views.js"; import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -109,6 +109,9 @@ export interface ShapeHost { /** Byte offset from an object pointer back to ScrCycHdr.buf. */ readonly cycleBufOffset: number; declare(decl: string): void; + /** The module constant for a string (LlEmitter.cstr), for a trap message + * an emitted accessor passes to the runtime. */ + cstr(text: string): string; /** Inline the small-object allocator's fast paths (llvm/alloc.ts). */ readonly inlineAlloc?: boolean; /** Record and class fields that store a scalar union's tag and bits @@ -2450,15 +2453,29 @@ export function emitRecordShapes( const inline = host.inlineUnions.record(field.type); const fieldTy = llFieldType(field.type, inline); if (host.recordViews?.targets.has(shape.id) === true) { - // A view's fields live in the viewed record (record-views.ts). + // A view's fields live in the viewed record (record-views.ts); a + // shape a partial view may present answers NULL for a field the + // viewed record lacks, so the copy stops with the program's own + // message (a source-level read threw first). + const partial = host.recordViews.partialTargets.has(shape.id); host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`); + const fromView = partial + ? [ + ` %sn${i} = icmp eq ptr %sw${i}, null`, + ` br i1 %sn${i}, label %sv${i}.absent, label %sv${i}.join`, + `sv${i}.absent:`, + ` call void @scr_trap(ptr ${host.cstr(PARTIAL_VIEW_ABSENT)})`, + ` unreachable`, + ] + : [` br label %sv${i}.join`]; + if (partial) host.declare(`declare void @scr_trap(ptr)`); clone.push( ` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, ...recordViewTestLines(host.sizeType, "%src", `%sr${i}v`), ` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`, `sv${i}.view:`, ` %sw${i} = call ptr @scr_record_view_slot(ptr %src, ${host.sizeType} ${i})`, - ` br label %sv${i}.join`, + ...fromView, `sv${i}.join:`, ` %sp${i} = phi ptr [ %so${i}, %${i === 0 ? "entry" : `sv${i - 1}.join`} ], [ %sw${i}, %sv${i}.view ]`, ` %sv${i} = load ${fieldTy}, ptr %sp${i}`, diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index f475f073b..3f79e6541 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -293,6 +293,8 @@ export class LlWalkers { index - base, (decl) => this.host.declare(decl), this.host.rcMode === undefined, + (text) => this.host.cstr(text), + this.host.recordViews?.partialTargets.has(shapeId) === true, ); } else { p = B.tmp(); diff --git a/packages/compiler/src/ir/fields.generated.ts b/packages/compiler/src/ir/fields.generated.ts index 86c6a15e5..1e8a193c1 100644 --- a/packages/compiler/src/ir/fields.generated.ts +++ b/packages/compiler/src/ir/fields.generated.ts @@ -2115,10 +2115,11 @@ export type IrFieldsCurrent = [ Unread["spreads"]>, readonly unknown[]>[number]>, object>, "arg" | "what">>, Unread["spreads"]>, readonly unknown[]>[number]>, object>["arg"]>>, Unread, "kind" | "callee" | "receiver" | "calleeName" | "calleeNameValue" | "args" | "spreads" | "type" | "loc">>, - Unread, "kind" | "value" | "preserveRefs" | "adopt" | "exact" | "inferredSite" | "origin" | "type" | "loc">>, + Unread, "kind" | "value" | "preserveRefs" | "adopt" | "exact" | "partialView" | "inferredSite" | "origin" | "type" | "loc">>, Unread["preserveRefs"]>>, Unread["adopt"]>>, Unread["exact"]>>, + Unread["partialView"]>>, Unread["origin"]>>, Unread, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>, Unread, "kind" | "value" | "fnName" | "fnLength" | "liveRef" | "type" | "loc">>, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 7200da6dd..2c211b386 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -4098,6 +4098,13 @@ export type IrExpr = /** `exact`: the value is known to be a capsule this program boxed from * exactly the class `type` names (a `dyn.typedRefKeyIs` test guards the * expression), so the check is one load of the capsule's instance. */ + /** `partialView`: `value` is `dynFrom{liveRef}` of a record whose fields + * are a SUBSET of `type`'s, with equal types — a partial record literal + * asserted to a wider record type (`{ truncated: true } as + * TruncationResult`). The conversion answers a partial record view + * (record-views.ts): a `type` value whose fields the source lacks are + * absent, so presence tests answer false and reads of them throw the + * catchable TypeError. */ /** `adopt`: the declaration-typed boundary's cast (decl-projection.ts). * The conversion preserves identity: a source object or array that * something else still references (reference count above one) is @@ -4116,6 +4123,7 @@ export type IrExpr = preserveRefs?: true; adopt?: true; exact?: true; + partialView?: true; /** Shape inference's overlay: this conversion checks an * analysis-proven fact (" :"); a failure is a * compiler analysis bug and reports itself as one. */ diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..3c32915db 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -6295,6 +6295,37 @@ function validateFunction( ) { err(`dynCheck against non-JSON-representable type ${e.type.kind}`, e.loc); } + if (e.partialView === true) { + // A partial record assertion (record-views.ts): the operand is a + // live record whose fields are a SUBSET of the target's, with + // equal types, so the view lacks only properties the record + // never had. + if ( + e.type.kind !== "record" || + e.value.kind !== "dynFrom" || + e.value.liveRef !== true || + e.value.value.type.kind !== "record" + ) { + err(`partialView dynCheck needs a live-record operand and a record target`, e.loc); + break; + } + const target = records.get(e.type.shapeId); + const source = records.get(e.value.value.type.shapeId); + if (!target || !source) { + err(`partialView dynCheck names an unknown record shape`, e.loc); + break; + } + for (const field of source.fields) { + const found = target.fields.find((f) => f.name === field.name); + if (!found || typeKey(found.type) !== typeKey(field.type)) { + err( + `partialView source field "${field.name}" is not a target field with an equal type`, + e.loc, + ); + break; + } + } + } break; } default: { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 343930580..1d2c88021 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1342,6 +1342,10 @@ typedef struct { size_t key_len; const size_t *offsets; } ScrRecordViewSource; +/* The slot of field `position` of a view, or NULL for a field the viewed + * record does not declare (a PARTIAL view holds SIZE_MAX; record-views.ts). + * A field address of a shape some partial view may present tests the answer + * and traps with the program's message. */ void *scr_record_view_slot(void *view, size_t position); void *scr_record_view_field(void *value, size_t offset, size_t position); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2ecc99d9c..14eab651b 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4404,10 +4404,17 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t return view; } -/* The slot of field `position` of a view (emitted view guards, cold). */ +/* The slot of field `position` of a view (emitted view guards, cold), or + * NULL when the view cannot have it: a PARTIAL view (a record literal + * asserted to a wider record type) holds SIZE_MAX for every field its + * viewed record does not declare. The guard that gets NULL stops with + * scr_trap and the program's own message; a presence test asks for the same + * slot and answers by it. */ void *scr_record_view_slot(void *view, size_t position) { ScrRecordSource *entry = scr_record_source_slot(view); - return (char *)entry->viewed + entry->offsets[position]; + size_t offset = entry->offsets[position]; + if (offset == SIZE_MAX) return NULL; + return (char *)entry->viewed + offset; } /* The address of field `position` of a value of a view target type, whose From bb1b0aac50ca6b1a5b6cf9613a9ffae3154665ee Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:27:32 -0700 Subject: [PATCH 124/193] Lower partial record assertions to record views `{ truncated: true } as TruncationResult` now keeps the literal's own layout: the assertion builds shape A from the literal's keys with the asserted type's field types and hands the asserted type B a partial view of it (record-views.ts). Fields the literal never wrote are absent from the view, so `in`, `Object.keys` and `JSON.stringify` answer exactly what Node answers, and a genuine value of the asserted type lowers as before. Reads, writes, destructuring and `in` tests of a field some asserted literal leaves unwritten are checked against the view, so they cannot reach a slot the view lacks: a read throws the catchable TypeError (Node yields undefined, a documented divergence), and a write throws before storing (Node would add the property). The site scan is one lazy whole-program pass (partial-assertions.ts) that records only the targets an object-literal `as` produces, so a program without one lowers exactly as it did. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/narrowing.ts | 9 + .../lowering/expressions/property-access.ts | 18 ++ .../lowering/expressions/relational.ts | 8 + .../frontend/lowering/expressions/updates.ts | 71 ++++-- .../src/frontend/lowering/lower-stmts.ts | 5 + .../compiler/src/frontend/lowering/lowerer.ts | 10 + .../frontend/lowering/partial-assertions.ts | 234 ++++++++++++++++++ .../record-partial-assertion-lowering.test.ts | 116 +++++++++ .../corpus/record-partial-assertion.ts.json | 6 + tests/corpus/record-partial-assertion.ts | 25 ++ .../record-partial-assertion.ts.json | 1 + 11 files changed, 485 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/partial-assertions.ts create mode 100644 packages/compiler/test/record-partial-assertion-lowering.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json create mode 100644 tests/corpus/record-partial-assertion.ts create mode 100644 tests/harness/corpus-costs/record-partial-assertion.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 6a6a25292..04f1d0d07 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -28,6 +28,7 @@ import { typeEquals, } from "../../../ir/ir.js"; import { isJsSourceFile, locOf } from "../../program.js"; +import { lowerPartialAssertion, partialAssertionSite } from "../partial-assertions.js"; import { hasRuntimeStatics } from "../class-runtime-statics.js"; import { jsBindingHasOpenWrites } from "../lower-stmts.js"; import { documentedParameter, prototypeAssignedFunction } from "../call-signatures.js"; @@ -1385,6 +1386,14 @@ export function lowerAsExpression( loc: locOf(expr), }; } + // A RECORD LITERAL asserted to a wider record type (`{ truncated: true } + // as TruncationResult`): keep the literal's own layout and let the + // asserted type view it (partial-assertions.ts). The record-to-record + // width copy below cannot build a value the literal does not have. + { + const partial = partialAssertionSite(lowerer, expr); + if (partial) return lowerPartialAssertion(lowerer, expr, partial); + } // `e as C` on a CATCH BINDING (`(err as Error).message`): the checked // extraction — an instanceof match extracts the payload, anything else // throws the catchable TypeError (dynCheck's trust-but-verify stance, diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e566ed292..db035b4d7 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -197,6 +197,7 @@ import { InternalCompilerError } from "../../../errors.js"; import { lowerUnionFieldRead } from "./union-fields.js"; import { errorPropertyRead } from "../error-methods.js"; import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; +import { partialAssertionFields, partialFieldRead } from "../partial-assertions.js"; import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/compression.js"; import { isWebCryptoReceiver } from "../builtins/crypto.js"; import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js"; @@ -4478,6 +4479,23 @@ export function fieldGetExpr( } return read; } + // A field some partial record assertion leaves unwritten (a partial + // record view, partial-assertions.ts): a read of an absent field throws + // the catchable TypeError instead of reading a slot the view lacks. + if (partialAssertionFields(lowerer, target.shapeId).has(target.field)) { + const obj = target.obj; + if (isSafeToRepeat(obj)) + return partialFieldRead(lowerer, obj, target.shapeId, target.field, target.fieldType, loc)!; + const stable = lowerer.declareHiddenLocal("%partialRecv", obj.type); + const ref = varRef(stable.id, obj.type, loc); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: obj, loc }], + result: partialFieldRead(lowerer, ref, target.shapeId, target.field, target.fieldType, loc)!, + type: target.fieldType, + loc, + }; + } return { kind: "recordGet", obj: target.obj, diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index a47e24908..bc90ce93f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -46,6 +46,7 @@ import { typedArrayHasMember } from "../builtins/typed-array-members.js"; import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js"; import { maybePromiseInstanceOf } from "../maybe-promise.js"; import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; +import { partialFieldPresence } from "../partial-assertions.js"; import { type ClassInfo, storedClassValueType } from "../lower-classes.js"; import { classInstanceOf } from "../class-dynamic-dispatch.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -2016,6 +2017,13 @@ export function lowerInExpression( if (!shape) throw new InternalCompilerError(`lowerer bug: unknown shape ${recv.type.shapeId}`); const field = shape.fields.find((f) => f.name === key); if (field) { + // A field some partial record assertion leaves unwritten (a partial + // record view, partial-assertions.ts): the answer is the view's, not + // the declared type's. + { + const viewPresence = partialFieldPresence(lowerer, recv, recv.type.shapeId, key, loc); + if (viewPresence) return viewPresence; + } if ( !OBJECT_PROTOTYPE_KEYS.has(key) && field.type.kind === "union" && diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 35d5d1a2e..1b151fa9f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -49,6 +49,7 @@ import { expandoWritableTarget } from "../lower-expando.js"; import { nsWritableTarget } from "../lower-namespaces.js"; import { arrayNumberRead, arrayValueRead, arrayValueStore } from "../array-values.js"; import { isSafeToRepeat } from "./evaluation-safety.js"; +import { partialFieldWriteError } from "../partial-assertions.js"; import { errorPropertyWrite } from "../error-methods.js"; import type { CompoundOp } from "../surfaces.js"; import { tryLowerExpression } from "./try-lower-expression.js"; @@ -888,24 +889,58 @@ export function fieldSetStmt( loc, }; } - return target.container === "class" - ? { - kind: "fieldSet", - obj: target.obj, - className: target.className, - field: target.field, - value, - ...(isStrictCode(blame) ? {} : { sloppy: true as const }), - loc, - } - : { - kind: "recordSet", - obj: target.obj, - shapeId: target.shapeId, - field: target.field, - value, - loc, - }; + if (target.container === "class") + return { + kind: "fieldSet", + obj: target.obj, + className: target.className, + field: target.field, + value, + ...(isStrictCode(blame) ? {} : { sloppy: true as const }), + loc, + }; + // A field some partial record assertion leaves unwritten (a partial + // record view, partial-assertions.ts): a view cannot grow a slot the + // asserted literal never wrote, so the write throws the catchable + // TypeError (Node would add the property — a documented divergence). + const refused = partialFieldWriteError(lowerer, target.shapeId, target.field, loc); + if (refused !== null) { + const obj = target.obj; + const stable = isSafeToRepeat(obj) + ? null + : lowerer.declareHiddenLocal("%partialWrite", obj.type); + const ref = stable ? varRef(stable.id, obj.type, loc) : obj; + return { + kind: "block", + body: [ + ...(stable ? [{ kind: "varDecl" as const, localId: stable.id, init: obj, loc }] : []), + { + kind: "if", + cond: { + kind: "recordHas", + obj: ref, + shapeId: target.shapeId, + field: target.field, + type: BOOL, + loc, + }, + then: [], + else_: [{ kind: "exprStmt", expr: refused, loc }], + loc, + }, + { kind: "recordSet", obj: ref, shapeId: target.shapeId, field: target.field, value, loc }, + ], + loc, + }; + } + return { + kind: "recordSet", + obj: target.obj, + shapeId: target.shapeId, + field: target.field, + value, + loc, + }; } export function lowerFieldCompoundValue( diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..b30ac824f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,5 +1,6 @@ import type { DiagnosticCode } from "../../diagnostics/codes.js"; import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js"; +import { partialFieldRead } from "./partial-assertions.js"; import { jsGlobalValueByName } from "./global-values.js"; import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js"; import { islandHandleBinding, lowerIslandExpr, lowerIslandSubclass } from "./lower-node-island.js"; @@ -3756,6 +3757,10 @@ export function lowerBindingPattern( type: fieldType, loc, }; + // A field some partial record assertion leaves unwritten (a partial + // record view, partial-assertions.ts): the element read throws the + // catchable TypeError instead of reading a slot the view lacks. + value = partialFieldRead(lowerer, srcRef(), srcType.shapeId, propName, fieldType, loc) ?? value; // `{ tld = "localhost" }`: the element default applies exactly when // the field holds the undefined arm — the same undefined test and // narrow/retag as a defaulted parameter's prologue (declareParams). diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..335e28d81 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -770,6 +770,7 @@ import type { ExpandoMembers } from "./lower-expando.js"; import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; +import { partialAssertionFields } from "./partial-assertions.js"; import { lowerTernary } from "./lower-exprs.js"; import { maybePromiseReturnStmt, @@ -3259,6 +3260,10 @@ export class Lowerer { /** The collection types that uses prove for untyped JavaScript bindings, * by every identifier naming one (js-collection-types.ts). */ inferredCollectionTypes: Map | undefined = undefined; + /** Partial record assertions (`partial-assertions.ts`): per target shape, + * the required fields some asserted literal leaves unwritten. */ + partialAssertions: Map; text: string }> | undefined = + undefined; /** overload-choice's answers per call, binding and function. */ readonly dynamicOverloadAnswers = new Map(); /** Error parameters of an error-first callback with a known value in @@ -4384,6 +4389,7 @@ export class Lowerer { "priorRetriedSymbols", "wholeSlotDegradation", "inferredCollectionTypes", + "partialAssertions", "foreignElementKeys", // caches made when first needed "nanCodedReads", @@ -14045,6 +14051,10 @@ export class Lowerer { * undefined-armed union fields, which can be absent, and `true` for * every other declared field. */ recordFieldPresent(obj: IrExpr, shapeId: string, field: string, loc: SrcLoc): IrExpr { + // A partial record view (partial-assertions.ts) can lack a field its + // asserted literal never wrote, whatever the field's required type. + if (partialAssertionFields(this, shapeId).has(field)) + return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }; const f = this.shapes.get(shapeId)?.fields.find((x) => x.name === field); if (f && f.type.kind === "union" && this.armTag(f.type.unionId, UNDEFINED_T) >= 0) { return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }; diff --git a/packages/compiler/src/frontend/lowering/partial-assertions.ts b/packages/compiler/src/frontend/lowering/partial-assertions.ts new file mode 100644 index 000000000..12d3c32b7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/partial-assertions.ts @@ -0,0 +1,234 @@ +/* Partial record assertions (`{ truncated: true } as TruncationResult`). + * + * A record literal carries exactly the fields it writes, but its asserted + * type may require more. Node erases the assertion: the value keeps the + * literal's properties, and reading a field it never had yields undefined. + * A record type has a fixed layout, so scriptc keeps the LITERAL's layout + * (shape A — the written fields, with the asserted type's field types) and + * hands the asserted type B a PARTIAL record view of it (record-views.ts): + * the value is a B whose record-source entry names A, and B fields A does + * not declare are absent. Reflection through the view is exact. + * + * This module finds those sites and answers, per target shape, which + * required fields some asserted literal leaves unwritten. The lowering + * consults it for member reads and writes, destructuring and `in`, so an + * absent field's read throws the catchable TypeError (Node yields + * undefined — a documented divergence) instead of reaching a slot the view + * does not have. Computed once for the whole program, on first use, like + * the untyped-collection inference (js-collection-types.ts). */ + +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import { nodeThrowExpr } from "../../ir/build.js"; +import { + BOOL, + DYN, + type IrExpr, + type IrType, + type SrcLoc, + UNDEFINED_T, + VOID, +} from "../../ir/ir.js"; + +/** One site's facts: the asserted record type, the literal's keys in + * source order, and the required B fields the literal does not write. */ +export interface PartialAssertionSite { + readonly target: IrType & { kind: "record" }; + readonly keys: readonly string[]; + readonly missing: ReadonlySet; + /** The asserted type's source spelling, for the throw messages. */ + readonly text: string; +} + +interface PartialTarget { + readonly missing: ReadonlySet; + readonly text: string; +} + +const NO_FIELDS: ReadonlySet = new Set(); + +/** The required fields some asserted literal leaves unwritten on shapes of + * `shapeId`; empty when no partial assertion produces that shape. */ +export function partialAssertionFields(lowerer: Lowerer, shapeId: string): ReadonlySet { + return partialTargetOf(lowerer, shapeId)?.missing ?? NO_FIELDS; +} + +function partialTargetOf(lowerer: Lowerer, shapeId: string): PartialTarget | null { + if (lowerer.partialAssertions === undefined) { + const found = new Map(); + lowerer.partialAssertions = found; + for (const file of lowerer.program.getImplementationSourceFiles()) { + if (isJsSourceFile(file)) continue; + ts.walkPreorder(file, (node) => { + if (!ts.isAsExpression(node) && !ts.isTypeAssertion(node)) return; + const site = partialAssertionSite(lowerer, node); + if (site === null) return; + const known = found.get(site.target.shapeId); + const missing = new Set(); + if (known !== undefined) { + for (const name of known.missing) missing.add(name); + } + for (const name of site.missing) missing.add(name); + found.set(site.target.shapeId, { missing, text: site.text }); + }); + } + } + return lowerer.partialAssertions.get(shapeId) ?? null; +} + +/** The partial-assertion facts of `expr`, or null when it asserts a record + * literal to a type that needs no partial view. */ +export function partialAssertionSite( + lowerer: Lowerer, + expr: ts.AsExpression | ts.TypeAssertion, +): PartialAssertionSite | null { + let operand: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if (!ts.isObjectLiteralExpression(operand)) return null; + const literal: ts.ObjectLiteralExpression = operand; + const keys: string[] = []; + for (const prop of literal.properties.nodes) { + if (!ts.isShorthandPropertyAssignment(prop) && !ts.isPropertyAssignment(prop)) return null; + const name = prop.name; + if (!ts.isIdentifier(name) && !ts.isStringLiteral(name)) return null; + // A repeated key is the last write's; the literal alone would not + // decide the shape. Keys ride the literal's own inferred shape. + if (keys.includes(name.text)) return null; + keys.push(name.text); + } + const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type); + const target = lowerer.mapTypeOf(targetTs); + if (target?.kind !== "record") return null; + const shape = lowerer.shapes.get(target.shapeId); + if (!shape || shape.tuple || shape.indexValue !== undefined) return null; + const missing = new Set(); + for (const key of keys) { + // A key the asserted type does not declare would ride the overflow + // map, which a view has no slots for. + if (!shape.fields.some((f) => f.name === key)) return null; + } + for (const field of shape.fields) { + if (keys.includes(field.name)) continue; + // Only a REQUIRED field can be missing: an undefined-armed slot holds + // the field's absent state, which the view keeps. + if (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0) + continue; + missing.add(field.name); + } + if (missing.size === 0) return null; + return { target, keys, missing, text: lowerer.checker.typeToString(targetTs) }; +} + +/** The checked read of `field` through a value of `shapeId` that a partial + * assertion may have produced, or null when the field is always present. + * `obj` is read twice (the presence test and the read), so it must be safe + * to repeat. */ +export function partialFieldRead( + lowerer: Lowerer, + obj: IrExpr, + shapeId: string, + field: string, + fieldType: IrType, + loc: SrcLoc, +): IrExpr | null { + const target = partialTargetOf(lowerer, shapeId); + if (target === null || !target.missing.has(field)) return null; + return { + kind: "ternary", + cond: { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }, + then: { kind: "recordGet", obj, shapeId, field, type: fieldType, loc }, + else_: nodeThrowExpr( + 1, + "", + `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, + fieldType, + loc, + ), + type: fieldType, + loc, + }; +} + +/** The TypeError a write of a field the asserted literal never wrote + * throws, or null when the field is always present. */ +export function partialFieldWriteError( + lowerer: Lowerer, + shapeId: string, + field: string, + loc: SrcLoc, +): IrExpr | null { + const target = partialTargetOf(lowerer, shapeId); + if (target === null || !target.missing.has(field)) return null; + return nodeThrowExpr( + 1, + "", + `Cannot set property '${field}': the object asserted as '${target.text}' has no such property`, + VOID, + loc, + ); +} + +/** The own-property presence test for `field` on a receiver of `shapeId` + * (`"f" in value`), or null when no partial assertion can leave it absent. + * `obj` must be safe to repeat. */ +export function partialFieldPresence( + lowerer: Lowerer, + obj: IrExpr, + shapeId: string, + field: string, + loc: SrcLoc, +): IrExpr | null { + const target = partialTargetOf(lowerer, shapeId); + if (target === null || !target.missing.has(field)) return null; + return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }; +} + +/** The checked conversion a partial assertion lowers to: the literal built + * at its OWN layout A (the asserted type's field types, values checked + * against them), boxed live, and asserted to B as a partial view. */ +export function lowerPartialAssertion( + lowerer: Lowerer, + expr: ts.AsExpression | ts.TypeAssertion, + site: PartialAssertionSite, +): IrExpr { + const loc = locOf(expr); + let literal: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(literal)) literal = literal.expression; + const shape = lowerer.shapes.get(site.target.shapeId)!; + // The site check accepted exactly the literal's property assignments and + // shorthands, in source order. + const props = (literal as ts.ObjectLiteralExpression).properties.nodes; + const fields = site.keys.map((key, i) => { + const prop = props[i]!; + const initializer = ts.isPropertyAssignment(prop) + ? prop.initializer + : (prop.name as unknown as ts.Expression); + const type = shape.fields.find((f) => f.name === key)!.type; + const value = lowerer.coerceInto(prop, lowerer.lowerExprExpecting(initializer, type), type); + return { name: key, type, value }; + }); + // A's identity is its name-sorted field list; its DECLARED order is the + // literal's, which JSON and Object.keys read through the view's capsule. + const shapeId = lowerer.shapes.intern( + [...fields] + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map((f) => ({ name: f.name, type: f.type })), + false, + undefined, + [...site.keys], + ); + const built: IrExpr = { + kind: "recordLit", + fields: fields.map((f) => ({ name: f.name, value: f.value })), + type: { kind: "record", shapeId }, + loc, + }; + return { + kind: "dynCheck", + partialView: true, + value: { kind: "dynFrom", value: built, liveRef: true, type: DYN, loc }, + type: site.target, + loc, + }; +} diff --git a/packages/compiler/test/record-partial-assertion-lowering.test.ts b/packages/compiler/test/record-partial-assertion-lowering.test.ts new file mode 100644 index 000000000..e7bad67b6 --- /dev/null +++ b/packages/compiler/test/record-partial-assertion-lowering.test.ts @@ -0,0 +1,116 @@ +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 { IrExpr } from "../src/ir/ir.js"; + +/** Every IR node in the module, in traversal order. */ +async function moduleNodes(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-record-partial-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const nodes: T[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown }; + if (typeof node.kind === "string") nodes.push(node as T); + for (const child of Object.values(value)) visit(child); + }; + visit(mod); + return nodes; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const TR = `interface TR { content: string; truncated: boolean; truncatedBy: "lines" | "bytes" | null } +`; + +test("a read of a field a partial assertion never wrote is presence-checked", async () => { + const nodes = await moduleNodes<{ + kind: string; + cond?: { kind: string; field?: string }; + then?: { kind: string }; + else_?: { kind: string; fn?: string; args?: IrExpr[] }; + }>( + `${TR}function read(value: TR): string { return value.content; } + read({ truncated: true } as TR); + export {}; + `, + ); + const guards = nodes.filter( + (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "content", + ); + expect(guards).toHaveLength(1); + const guard = guards[0]!; + expect(guard.then).toMatchObject({ kind: "recordGet" }); + // Node yields undefined; the view throws the catchable TypeError instead. + expect(guard.else_).toMatchObject({ + kind: "libCall", + fn: "error.nodeThrow", + args: [ + { kind: "numLit", value: 1 }, + { kind: "strLit" }, + { + kind: "strLit", + value: "Cannot read property 'content': the object asserted as 'TR' has no such property", + }, + ], + }); +}); + +test("a field the literal writes reads without a presence check", async () => { + const nodes = await moduleNodes<{ kind: string; cond?: { kind: string } }>( + `${TR}function read(value: TR): boolean { return value.truncated; } + read({ truncated: true, content: "c" } as TR); + export {}; + `, + ); + expect(nodes.some((n) => n.kind === "ternary" && n.cond?.kind === "recordHas")).toBe(false); +}); + +test("a write of a field a partial assertion never wrote throws before storing", async () => { + const nodes = await moduleNodes<{ + kind: string; + body?: { kind: string; cond?: { kind: string; field?: string }; else_?: unknown[] }[]; + }>( + `${TR}function write(value: TR): void { value.content = "x"; } + write({ truncated: true } as TR); + export {}; + `, + ); + const guard = nodes + .filter((n) => n.kind === "block") + .flatMap((n) => n.body ?? []) + .find((s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content"); + expect(guard).toBeDefined(); + expect(guard!.else_).toMatchObject([ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "error.nodeThrow", + args: [ + { kind: "numLit", value: 1 }, + { kind: "strLit" }, + { + kind: "strLit", + value: + "Cannot set property 'content': the object asserted as 'TR' has no such property", + }, + ], + }, + }, + ]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json new file mode 100644 index 000000000..b0464bd69 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-partial-assertion.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-partial-assertion.ts b/tests/corpus/record-partial-assertion.ts new file mode 100644 index 000000000..6a9b2c93b --- /dev/null +++ b/tests/corpus/record-partial-assertion.ts @@ -0,0 +1,25 @@ +// A partial `as` assertion keeps the literal's own layout and answers the +// asserted type through a record view: fields the literal never wrote are +// absent, and reflection sees exactly what Node sees. +interface TruncationResult { content: string; truncated: boolean; truncatedBy: "lines" | "bytes" | null; totalLines: number; firstLineExceedsLimit: boolean } +class Panel { + private truncation?: TruncationResult; + setComplete(truncation?: TruncationResult): void { this.truncation = truncation; } + summary(): string { return `${this.truncation?.truncated ?? false}`; } + dump(): string { return JSON.stringify(this.truncation ?? null); } + keys(): string { return this.truncation ? Object.keys(this.truncation).join(",") : "-"; } + hasContent(): boolean { return this.truncation !== undefined && "content" in this.truncation; } +} +function render(truncated: boolean, output: string): string { + const a = new Panel(); + a.setComplete(truncated ? ({ truncated: true } as TruncationResult) : undefined); + const b = new Panel(); + b.setComplete(truncated ? ({ truncated: true, content: output } as TruncationResult) : undefined); + return [a.summary(), a.dump(), a.keys(), a.hasContent(), b.summary(), b.dump(), b.keys(), b.hasContent()].join(" "); +} +console.log(render(true, "out")); +console.log(render(false, "out")); +const full: TruncationResult = { content: "c", truncated: false, truncatedBy: null, totalLines: 3, firstLineExceedsLimit: false }; +const p = new Panel(); p.setComplete(full); +console.log(p.summary(), p.dump(), p.keys(), p.hasContent(), full.totalLines + 1); +export {}; diff --git a/tests/harness/corpus-costs/record-partial-assertion.ts.json b/tests/harness/corpus-costs/record-partial-assertion.ts.json new file mode 100644 index 000000000..618ba4e43 --- /dev/null +++ b/tests/harness/corpus-costs/record-partial-assertion.ts.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 24500 } From ac414df975685f5da857b1dc0c1294b1ed7c98f9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 23:56:18 -0700 Subject: [PATCH 125/193] Select the partial-view runtime build per program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record view's slot lookup answers NULL for a field a partial view lacks (record-views.ts), but only a program that asserts a record literal to a wider type ever asks for such a slot. Testing for the sentinel inside the default scr_record_view_slot put a branch on a hot accessor of every program that links a view: the zero-cost gate found fifteen typed programs whose executables changed with byte-identical IR and LLVM. Build the unit twice instead, as the fs.watchFile work does for its unit. The default object is again byte-for-byte the one it was, and the SCR_RECORD_VIEW_PARTIAL build — the one whose slot answers NULL — is selected by the program's link features. `recordViewPartial` joins the runtime features, set by a `partialView` dynCheck and carried through the link features, the executable options and the early (cache-key) features, so a cached executable can never link the other build. The runtime pack gains the eight builds that pair the flag with the unit's existing dynTier, webStreams and nodeIsland combinations; every program without a partial assertion keeps the default object. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + .../src/backend/native-link-info-core.ts | 4 ++++ .../compiler/src/backend/native/contracts.ts | 3 +++ .../compiler/src/backend/native/executable.ts | 3 +++ .../compiler/src/backend/runtime-pack-core.ts | 2 ++ .../src/executable/executable-cache.ts | 1 + packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/runtime-features.ts | 7 +++++++ packages/compiler/src/native/sanitizer.test.ts | 1 + .../runtime-pack-matrix.mjs | 15 +++++++++++++++ packages/runtime/src/scr_dyn.h | 7 +++---- packages/runtime/src/scr_json.c | 18 ++++++++++++------ 12 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..25509f21f 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -19,6 +19,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.eventTarget ? { eventTarget: true } : {}), ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), + ...(features.recordViewPartial ? { recordViewPartial: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..e6530bbfa 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -31,6 +31,10 @@ export interface NativeLinkFeatures { /** The program assigns process.emitWarning: links the * SCR_WARNING_REPLACE variant of the dyn-async unit. */ warningReplace?: boolean; + /** The program asserts a record literal to a wider record type (a partial + * record view): links the SCR_RECORD_VIEW_PARTIAL variant of scr_json.c. + * Absent for every program without one, so their objects stay identical. */ + recordViewPartial?: boolean; /** The program uses process.stdin.setEncoding, string 'data' listeners * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ stdioExtended?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..92b3a532e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -159,6 +159,9 @@ export interface CcOptions { wasm?: boolean; /** SCR_WARNING_REPLACE: process.emitWarning replacement support. */ warningReplace?: boolean; + /** SCR_RECORD_VIEW_PARTIAL: the scr_json.c build whose view slot answers + * NULL for a field a partial record view does not have. */ + recordViewPartial?: boolean; /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ stdioExtended?: boolean; /** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..3581abe4f 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -408,6 +408,9 @@ const RUNTIME_DEFINES: readonly { // programs with dynamic-tier code select it. { define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true }, { define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace }, + // scr_json.c's view slot answers NULL for a field a partial record view + // does not have; only a program with a partial assertion selects it. + { define: "SCR_RECORD_VIEW_PARTIAL", when: (opts) => opts.recordViewPartial }, { define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended }, // scr_async.c's native promise reactions, which the web streams unit uses, // scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes, diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 2ce4f21fe..d63e0536b 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -100,6 +100,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { builtinValues: boolean; computedRequire: boolean; warningReplace: boolean; + recordViewPartial: boolean; stdioExtended: boolean; nodeIsland: boolean; brotli: boolean; @@ -311,6 +312,7 @@ export function effectiveRuntimeFeatures( builtinValues: features.builtinValues === true, computedRequire: features.computedRequire === true, warningReplace: features.warningReplace === true, + recordViewPartial: features.recordViewPartial === true, stdioExtended: features.stdioExtended === true, nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..9bb13607c 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -64,6 +64,7 @@ export interface EarlyExecutableNativeFeatures { eventTarget?: boolean; wasm?: boolean; warningReplace?: boolean; + recordViewPartial?: boolean; stdioExtended?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..cea853906 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -699,6 +699,7 @@ async function compileExecutableNative( ...(features.eventTarget ? { eventTarget: true } : {}), ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), + ...(features.recordViewPartial ? { recordViewPartial: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..3ec671702 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1206,6 +1206,12 @@ const RUNTIME_FEATURE_RULES = { utilPromisify: rule({ fns: ["util.promisifyDyn"] }), /** process.emitWarning is assigned (process.setEmitWarning). */ warningReplace: rule({ fns: ["process.setEmitWarning"] }), + /** A record literal asserted to a wider record type answers a partial + * record view (record-views.ts): the SIZE_MAX sentinel and its trap live in + * the SCR_RECORD_VIEW_PARTIAL build of scr_json.c. */ + recordViewPartial: rule({ + expr: (node) => node.kind === "dynCheck" && node.partialView === true, + }), /** Module namespace objects as checked-dynamic values * (scr_module_namespace.c). */ moduleNamespace: rule({ fns: ["module.namespaceValue"] }), @@ -1326,6 +1332,7 @@ function noRuntimeFeatures(): RuntimeFeatures { tlsCa: false, utilPromisify: false, warningReplace: false, + recordViewPartial: false, wasm: false, webcrypto: false, zlib: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index b1d03b52d..a89e4ee45 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -31,6 +31,7 @@ const features = { builtinValues: false, computedRequire: false, warningReplace: false, + recordViewPartial: false, stdioExtended: false, nodeIsland: false, brotli: false, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..b883bf9ec 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -247,6 +247,21 @@ export function variantsFor(source) { when: { dynTier: true, webStreams: true, nodeIsland: true }, defines: ["SCR_JS_TIER", "SCR_HANDLE_CLASSES", "SCR_NODE_ISLANDS"], }); + // A program with a partial record assertion (record-views.ts) links the + // build whose view slot answers NULL for a field the viewed record does + // not declare, so the emitted guard, clone helper and presence test can + // stop with the program's message instead of reading a bogus slot. The + // selector takes the variant with the most conditions, so each of the + // builds above needs its counterpart; every other program keeps the + // default object, byte for byte. + for (const { id, when, defines } of variants.slice()) { + const suffix = id === "default" ? "" : `-${id}`; + variants.push({ + id: `record-view-partial${suffix}`, + when: { ...when, recordViewPartial: true }, + defines: [...defines, "SCR_RECORD_VIEW_PARTIAL"], + }); + } } // util.inspect and instanceof of Node island handles. if (source === "scr_inspect.c" || source === "scr_dyn_builtin_class.c") { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 1d2c88021..fc0033163 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1342,10 +1342,9 @@ typedef struct { size_t key_len; const size_t *offsets; } ScrRecordViewSource; -/* The slot of field `position` of a view, or NULL for a field the viewed - * record does not declare (a PARTIAL view holds SIZE_MAX; record-views.ts). - * A field address of a shape some partial view may present tests the answer - * and traps with the program's message. */ +/* The slot of field `position` of a view. The SCR_RECORD_VIEW_PARTIAL build + * of scr_json.c, which only a program with a partial record assertion links, + * answers NULL for a field the viewed record does not declare (record-views.ts). */ void *scr_record_view_slot(void *view, size_t position); void *scr_record_view_field(void *value, size_t offset, size_t position); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 14eab651b..a71e4370e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4404,17 +4404,23 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t return view; } -/* The slot of field `position` of a view (emitted view guards, cold), or - * NULL when the view cannot have it: a PARTIAL view (a record literal - * asserted to a wider record type) holds SIZE_MAX for every field its - * viewed record does not declare. The guard that gets NULL stops with - * scr_trap and the program's own message; a presence test asks for the same - * slot and answers by it. */ +/* The slot of field `position` of a view (emitted view guards, cold). A + * PARTIAL view (a record literal asserted to a wider record type) holds + * SIZE_MAX for every field its viewed record does not declare: the + * SCR_RECORD_VIEW_PARTIAL build of this unit answers NULL for it, the guard + * that gets NULL stops with scr_trap and the program's own message, and a + * presence test asks for the same slot and answers by it. Only a program + * with such an assertion links that build (runtime-pack-matrix.mjs); every + * other program links this default object, whose bytes are unchanged. */ void *scr_record_view_slot(void *view, size_t position) { ScrRecordSource *entry = scr_record_source_slot(view); +#ifdef SCR_RECORD_VIEW_PARTIAL size_t offset = entry->offsets[position]; if (offset == SIZE_MAX) return NULL; return (char *)entry->viewed + offset; +#else + return (char *)entry->viewed + entry->offsets[position]; +#endif } /* The address of field `position` of a value of a view target type, whose From ad8f8daccd5e193cb54fa239d07d9167a9f7671e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 20:56:10 -0700 Subject: [PATCH 126/193] Keep scr_lib.c's default object byte-identical for the fs.watchFile poll Adding scr_stats_of_path and scr_stats_zero to scr_lib.c moved every program's executable: scr_lib.o is linked everywhere, so its new symbols and strings shifted addresses even for programs that never watch a file. The two entry points now live in an SCR_FS_WATCH_FILE build of scr_lib.c, the matrix's variant for programs whose feature set has watchFile, and the poll unit calls the renamed scr_stats_poll_path. Programs that do not poll link the default object unchanged: compiled without the define, scr_lib.o is byte-identical to the base (verified by comparing the two objects). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/native/executable.ts | 2 ++ .../runtime-pack-common/runtime-pack-matrix.mjs | 11 +++++++++++ packages/runtime/src/scr_lib.c | 14 ++++++++------ packages/runtime/src/scr_lib.h | 6 ++++-- packages/runtime/src/scr_watch_file.c | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 8ecac9923..8222bd9b7 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -426,6 +426,8 @@ const RUNTIME_DEFINES: readonly { { define: "SCR_NATIVE_REACTIONS", when: (opts) => opts.webStreams }, { define: "SCR_HANDLE_CLASSES", when: (opts) => opts.webStreams }, { define: "SCR_YIN", when: (opts) => opts.youngNursery }, + // scr_lib.c's path stat and zero Stats for the fs.watchFile poll. + { define: "SCR_FS_WATCH_FILE", when: (opts) => opts.watchFile }, { define: "SCR_NODE_ISLANDS", when: (opts) => opts.nodeIsland }, ]; diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6fff2b653..41eef2b23 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -274,6 +274,17 @@ export function variantsFor(source) { defines: ["SCR_TEXT_DECODER_LEGACY"], }); } + // fs.watchFile's poll needs a path stat that reports the errno (its failed + // poll IS a reportable event). The Stats snapshot is private to scr_lib.c, + // so the two entry points live in a build of that unit only programs that + // poll link; every other program keeps the default object, byte for byte. + if (source === "scr_lib.c") { + variants.push({ + id: "fs-watch-file", + when: { watchFile: true }, + defines: ["SCR_FS_WATCH_FILE"], + }); + } // Programs that assign process.emitWarning link the replacement-aware // warning paths; every other program keeps the default object. if (source === "scr_async_dyn.c") { diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index c47339a68..f10b4f5fa 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5584,7 +5584,7 @@ static bool scr_stats_link_target(HANDLE h, DWORD tag, double *size_out, ScrStr return true; } -static ScrStats *scr_stats_win_handle_stats(const ScrStr *path, const char *op, +static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, bool no_follow) { WCHAR *wide = scr_fs_win_wide(path); if (!wide) return scr_stats_crt_fallback(path, op); @@ -5630,7 +5630,7 @@ static ScrStats *scr_stats_win_handle_stats(const ScrStr *path, const char *op, /* Reparse points are a general interception mechanism. Node/libuv * follows non-link tags even for lstat, so reopen the resolved target. */ CloseHandle(h); - return scr_stats_win_handle_stats(path, op, false); + return scr_stats_of_path(path, op, false); } if (!scr_stats_link_target(h, tagged.ReparseTag, &link_size, NULL)) { /* A mount-point tag may name a mounted volume rather than a junction. @@ -5638,7 +5638,7 @@ static ScrStats *scr_stats_win_handle_stats(const ScrStr *path, const char *op, * ordinary reparse points and retries with the final component * followed. Do not classify from the tag alone. */ CloseHandle(h); - return scr_stats_win_handle_stats(path, op, false); + return scr_stats_of_path(path, op, false); } is_link = true; } @@ -6081,7 +6081,7 @@ ScrBytes *scr_fs_readlink_bytes(ScrStr *path) { ScrStats *scr_fs_stat(ScrStr *path) { if (!scr_fs_path_ok(path, "path")) return NULL; #ifdef _WIN32 - return scr_stats_win_handle_stats(path, "stat", false); + return scr_stats_of_path(path, "stat", false); #else int error = 0; ScrStats *s = scr_stats_posix(path->data, -1, false, &error); /* follows symlinks */ @@ -6093,7 +6093,7 @@ ScrStats *scr_fs_stat(ScrStr *path) { ScrStats *scr_fs_lstat(ScrStr *path) { if (!scr_fs_path_ok(path, "path")) return NULL; #ifdef _WIN32 - return scr_stats_win_handle_stats(path, "lstat", true); + return scr_stats_of_path(path, "lstat", true); #else int error = 0; ScrStats *s = scr_stats_posix(path->data, -1, true, &error); /* no follow */ @@ -6135,10 +6135,11 @@ ScrStats *scr_fs_fstat(double fd) { return s; } +#ifdef SCR_FS_WATCH_FILE /* fs.watchFile's poll (scr_watch_file.c calls this): stat(2) following * symlinks, libuv's uv_fs_stat — NULL with *error = errno on failure, * never throwing, because a failed poll IS a reportable event. +1. */ -ScrStats *scr_stats_of_path(ScrStr *path, int *error) { +ScrStats *scr_stats_poll_path(ScrStr *path, int *error) { if (!scr_fs_path_ok(path, "path")) { *error = EINVAL; return NULL; @@ -6161,6 +6162,7 @@ ScrStats *scr_stats_of_path(ScrStr *path, int *error) { * field 0, so isFile()/isDirectory() and the size/date reads all answer * zero/false. +1. */ ScrStats *scr_stats_zero(void) { return scr_stats_new(); } +#endif /* SCR_FS_WATCH_FILE */ /* ── Stats objects ─────────────────────────────────────────────────── * Node's Stats and BigIntStats over a snapshot. The integers and the *Ms diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 775f04e33..69c470217 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -507,8 +507,10 @@ double scr_stats_big_birthtime(ScrStats *s); /* fstat without throwing: +1, or NULL with *error set to the errno. */ ScrStats *scr_stats_of_fd(int fd, int *error); /* stat(2) following symlinks without throwing (the fs.watchFile poll): - * +1, or NULL with *error set to the errno. */ -ScrStats *scr_stats_of_path(ScrStr *path, int *error); + * +1, or NULL with *error set to the errno. Both this and scr_stats_zero + * exist only in the SCR_FS_WATCH_FILE build of scr_lib.c, which programs + * that poll link; every other program links the default object. */ +ScrStats *scr_stats_poll_path(ScrStr *path, int *error); /* The all-zero snapshot a failed poll reports (Node's kZeroStats). +1. */ ScrStats *scr_stats_zero(void); /* Node's Stats (or BigIntStats) object for the snapshot: own fields in the diff --git a/packages/runtime/src/scr_watch_file.c b/packages/runtime/src/scr_watch_file.c index b1bee2826..325e10adb 100644 --- a/packages/runtime/src/scr_watch_file.c +++ b/packages/runtime/src/scr_watch_file.c @@ -171,7 +171,7 @@ static void scr_watch_file_poll(ScrClosure *cb) { if (p == NULL) return; p->timer = 0; /* the loop consumed this arm */ int err = 0; - ScrStats *cur = scr_stats_of_path(p->path, &err); + ScrStats *cur = scr_stats_poll_path(p->path, &err); if (cur == NULL) { if (p->state != -err) { scr_watch_file_emit(p, NULL, p->last, -err); From 9021a031338f6951330ca0fe636febaceaf338bb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 07:50:05 -0500 Subject: [PATCH 127/193] Pass a shared view whole to a replaced stdout or stderr write A shared view written to stdout or stderr went out in 64 KiB pieces through scr_stdio_write, so a replaced process.stdout.write received the first piece alone, and a false return, which only reports backpressure, ended the loop: a view larger than 64 KiB lost the rest. With a write hook, the view is now copied whole and passed in one call, as Node passes it; the bounded pieces go to the stream itself through scr_stdio_write_raw, which never stops early. tests/corpus/shared-bytes-stdio-write.ts replaces process.stdout.write with a function that records each chunk's length and returns false, and writes a 150,000-byte shared view through it. --- packages/runtime/src/scr_bytes_io.c | 23 +++++++++++++------ tests/corpus/shared-bytes-stdio-write.ts | 13 +++++++++++ .../shared-bytes-stdio-write.ts.json | 2 +- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 2459d2e05..423b953b7 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1227,23 +1227,32 @@ ScrBytes *scr_crypto_random_bytes(double n) { /* ── process.stdout/stderr.write(buf) ──────────────────────────────────── */ /* Where another thread can write shared bytes (where SCR_BYTES_SNAPSHOT - * copies them), they are written through a bounded local buffer, read with - * scr_shared_load, so no shared raw memory reaches the system call and a - * large shared view is not copied whole first. */ + * copies them), they are read with scr_shared_load, so no shared raw memory + * reaches the system call. Written to the stream itself, they go through a + * bounded local buffer, so a large shared view is not copied whole first. + * With a write hook (the program reads process.stdout or process.stderr as + * a value, and may have replaced its write), the view is copied whole: a + * replaced write receives it in one call, as in Node, whatever it returns. */ static bool scr_stdio_write_bytes(int fd, const ScrBytes *b) { size_t n = b->len * scr_bytes_elem_size(b->elem); #if defined(SCR_WORKERS) || (defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)) if (!b->shared) return scr_stdio_write(fd, b->data, n); + if (scr_stdio_write_hook) { + unsigned char *copy = scr_mem_alloc(n ? n : 1); + scr_shared_load(copy, b->data, n); + bool ok = scr_stdio_write(fd, copy, n); + scr_mem_free(copy); + return ok; + } enum { CHUNK = 64 * 1024 }; unsigned char *chunk = scr_mem_alloc(n < CHUNK ? (n ? n : 1) : CHUNK); - bool ok = true; - for (size_t off = 0; ok && off < n; off += CHUNK) { + for (size_t off = 0; off < n; off += CHUNK) { size_t k = n - off < CHUNK ? n - off : CHUNK; scr_shared_load(chunk, b->data + off, k); - ok = scr_stdio_write(fd, chunk, k); + (void)scr_stdio_write_raw(fd, chunk, k); } scr_mem_free(chunk); - return ok; + return true; #else return scr_stdio_write(fd, b->data, n); #endif diff --git a/tests/corpus/shared-bytes-stdio-write.ts b/tests/corpus/shared-bytes-stdio-write.ts index 0a138fb77..a0d4b4e92 100644 --- a/tests/corpus/shared-bytes-stdio-write.ts +++ b/tests/corpus/shared-bytes-stdio-write.ts @@ -18,6 +18,19 @@ if (isMainThread) { process.stderr.write(new Uint8Array(sab, 63, 70_000)); process.stderr.write("\n"); + + // A replaced write receives the whole view in one call, even when it + // returns false. + const out = process.stdout as unknown as Record; + const original = out["write"]; + const sizes: number[] = []; + out["write"] = (chunk: unknown): boolean => { + sizes.push(typeof chunk === "string" ? chunk.length : (chunk as Uint8Array).length); + return false; + }; + const accepted = process.stdout.write(new Uint8Array(sab, 1000, 150_000)); + out["write"] = original; + console.log("replaced", accepted, sizes.join(",")); console.log("done"); }); } else { diff --git a/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json b/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json index 46d9e66e9..56f905494 100644 --- a/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json +++ b/tests/harness/corpus-costs/shared-bytes-stdio-write.ts.json @@ -1 +1 @@ -{ "plain": 8136, "san": 68380 } +{ "plain": 7025, "san": 74291 } From dd927301fc177f6fcca738c95ad7ffdbcda18206 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 07:57:38 -0500 Subject: [PATCH 128/193] Read builtins into untyped TypeScript slots as Node's values In a TypeScript source, a builtin global used as an object literal member (`const o: any = { j: JSON }`) failed to lower, and the member silently became a closure that threw when called. Node reads the global itself, so `typeof o.j` answered "function" instead of "object", two reads were never equal, and `o.j.stringify(x)` threw "is not a function". There was no diagnostic. A builtin read into a slot the checker types as any or unknown now holds the value a JavaScript source reads: the JSON, Math and Reflect namespace objects, Buffer, and functions such as structuredClone, atob and queueMicrotask. This covers object and array literal members, arguments, initializers and return values. A member of a literal whose own slot is any takes the literal's slot. Array, setTimeout and setImmediate keep their refusal: their values lack Array's statics and the timers' util.promisify.custom getter. Object literal members of TypeScript sources no longer defer a failed lowering to a runtime closure. A builtin with no value (Intl, Proxy, parseInt, process, a typed slot) reports SC2020 at compile time. util.inspect now prints a plain object's Symbol.toStringTag as Node does: `Object [Math] {}`, `[Object [Y]]` past the depth limit, and `[Object: null prototype] [N] {}`. The tag is read through the prototype chain and getters, and it is skipped when it is empty, not a string, the name "Object", listed as an own enumerable key, or its getter throws. --- .../lowering/expressions/identifiers.ts | 4 +- .../lowering/expressions/object-literals.ts | 11 ++-- .../src/frontend/lowering/global-values.ts | 49 +++++++++++++++++ .../builtin-values-untyped-slots.ts.json | 6 +++ .../object-to-string-tag-inspect.mjs.json | 6 +++ .../builtin-values-without-lowering.ts.json | 6 +++ packages/runtime/src/scr_dyn.h | 3 ++ packages/runtime/src/scr_inspect.c | 51 ++++++++++++++++++ packages/runtime/src/scr_json.c | 1 + tests/corpus/builtin-values-untyped-slots.ts | 53 +++++++++++++++++++ tests/corpus/object-to-string-tag-inspect.mjs | 37 +++++++++++++ .../builtin-values-without-lowering.ts | 11 ++++ .../builtin-values-without-lowering.ts.txt | 53 +++++++++++++++++++ .../builtin-values-untyped-slots.ts.json | 1 + .../object-to-string-tag-inspect.mjs.json | 1 + 15 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json create mode 100644 tests/corpus/builtin-values-untyped-slots.ts create mode 100644 tests/corpus/object-to-string-tag-inspect.mjs create mode 100644 tests/diagnostics/builtin-values-without-lowering.ts create mode 100644 tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt create mode 100644 tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json create mode 100644 tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index ebe7e2556..45c100535 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -1,5 +1,5 @@ /* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */ -import { globalMemberValue } from "../global-values.js"; +import { globalMemberValue, tsUntypedGlobalValue } from "../global-values.js"; import { predeclareForwardClass } from "../lower-stmts.js"; import { cjsMemberAliasValueOf } from "../cjs-exports-object.js"; import { builtinClassValue, builtinMemberClassKind } from "../builtins/builtin-class-dyn.js"; @@ -825,6 +825,8 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: if (isJsSourceFile(ts.getSourceFileOfNode(expr))) { return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc }; } + const untyped = tsUntypedGlobalValue(lowerer, expr, canonical, loc); + if (untyped !== null) return untyped; // The families with a WHY: each hint states what makes the // surface genuinely non-static (or what to use instead). const globalHints: Record = { diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index a3d2a5b11..e5b464edc 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -47,6 +47,7 @@ import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs. import { builtinGlobalValue } from "./property-access.js"; import { holdsEvolvingArray } from "../lower-stmts.js"; import { builtinNamespaceValue } from "../builtin-namespaces.js"; +import { tsUntypedGlobalValue } from "../global-values.js"; /** `{ a: 1, b: "x" }` → recordLit. The record type comes from the * contextual type when tsc has one (annotated declarations, arguments, @@ -446,8 +447,11 @@ export function lowerDynObjectLiteral( // A PURE member read a JS file cannot lower (a namespace object // in an export aggregate): the slot takes a boxed fence closure — // the diagnostics defer to the runtime-fence ledger, the object - // builds, and only USING the member stops the run. - const pureMember = ts.isIdentifier(valueExpr); + // builds, and only USING the member stops the run. A TypeScript + // source keeps the compile-time refusal: the closure would answer + // typeof "function" and undefined members where Node reads the + // value. + const pureMember = ts.isIdentifier(valueExpr) && isJsSourceFile(ts.getSourceFileOfNode(prop)); if (!(err instanceof PoisonError) || !pureMember) throw err; const fence = lowerer.deferToRuntimeFence(propDiagsBefore, prop, { kind: "closure", @@ -3540,7 +3544,8 @@ function shorthandBuiltinValue( const name = ts.symbolName(symbol); return ( (js ? builtinNamespaceValue(lowerer, name, locOf(prop.name)) : null) ?? - builtinGlobalValue(name, locOf(prop.name), js) + builtinGlobalValue(name, locOf(prop.name), js) ?? + tsUntypedGlobalValue(lowerer, prop.name as ts.Identifier, name, locOf(prop.name)) ); } diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index b5d5a7225..5b8d828e9 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -138,6 +138,55 @@ export function jsGlobalValueByName( ); } +/** Globals whose values a TypeScript read keeps refused: the Array + * constructor value has none of Array's static members (`o.Array.isArray` + * would read undefined), and the timer values lack the + * util.promisify.custom getter Node's util.inspect lists. */ +const UNTYPED_UNMODELED: ReadonlySet = new Set(["Array", "setTimeout", "setImmediate"]); + +/** The value a TypeScript source reads for the declared global `name` at + * `node` when the checker types the slot it flows into as any or unknown + * (`const o: any = { j: JSON }`, a call to a parameter typed unknown): the + * value a JavaScript source reads. Null in JavaScript sources, in typed + * slots, and for a global without a value, whose read keeps its SC2020 + * refusal. Null for the primitive constructors too: a TypeScript read of + * String, Number, Boolean or BigInt is the closure with the typed + * signature (lowerIdentifierExpr), and `{ String }` must equal it. */ +export function tsUntypedGlobalValue( + lowerer: Lowerer, + node: ts.Expression, + name: string, + loc: SrcLoc, +): IrExpr | null { + if (isJsSourceFile(ts.getSourceFileOfNode(node)) || UNTYPED_UNMODELED.has(name)) return null; + if (name === "String" || name === "Number" || name === "Boolean" || name === "BigInt") + return null; + if (!flowsIntoUntypedSlot(lowerer, node)) return null; + return jsGlobalValueByName(lowerer, name, loc); +} + +/** Whether the checker types the slot `node` flows into as any or unknown. + * A member of an object or array literal whose own slot is any has no + * contextual type, so the literal's slot decides. */ +function flowsIntoUntypedSlot(lowerer: Lowerer, node: ts.Expression): boolean { + let value: ts.Expression = node; + for (;;) { + const slot = lowerer.checker.getContextualType(value); + if (slot !== undefined) return (slot.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0; + const parent = value.parent; + if ( + (ts.isPropertyAssignment(parent) && parent.initializer === value) || + ts.isShorthandPropertyAssignment(parent) + ) { + value = parent.parent as ts.ObjectLiteralExpression; + } else if (ts.isArrayLiteralExpression(parent) || ts.isParenthesizedExpression(parent)) { + value = parent; + } else { + return false; + } + } +} + /** jsGlobalValueByName for the globals other than the interned builtin * functions and constructors (builtinGlobalValue). */ export function jsSpecialGlobalValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json new file mode 100644 index 000000000..1d0c4e6d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/builtin-values-untyped-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json new file mode 100644 index 000000000..3ecd83187 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/object-to-string-tag-inspect.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json new file mode 100644 index 000000000..e86bb86ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/builtin-values-without-lowering.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index f4bc8db68..7cd0c2b19 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1220,6 +1220,9 @@ void scr_dyn_install_species_symbol(ScrDyn *key); /* Records each well-known symbol the symbol unit creates. */ void scr_dyn_install_well_known_symbol(ScrDyn *key); void scr_dyn_install_to_string_tag_symbol(ScrDyn *key); +/* Symbol.toStringTag (borrowed), or NULL before the program first + * evaluates it. */ +const ScrDyn *scr_dyn_to_string_tag_symbol(void); /* Whether key is Symbol.asyncIterator. */ bool scr_dyn_is_async_iterator_symbol(const ScrDyn *key); /* Symbol.asyncIterator (+1), or NULL before the symbol unit installed it. */ diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index a015c25fe..84a370aa9 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1708,7 +1708,48 @@ static bool insp_obj_shows(const ScrDynProp *ent) { return ent->enumerable || (g_show_hidden && !(ent->key_len && ent->key[0] == '%')); } +/* The @@toStringTag Node brackets after a plain object's prefix + * (`Object [Math] {}`), +1: a nonempty string read through the object and + * its prototypes, unless the object's own tag is listed with its keys + * (an enumerable one, or any under showHidden). NULL otherwise, and when + * the tag's getter throws, as in Node. */ +static ScrStr *insp_plain_object_tag(ScrDyn *d) { + const ScrDyn *key = scr_dyn_to_string_tag_symbol(); + if (!key) return NULL; + ScrDynProp own; + if (scr_dyn_own_symbol_prop(d, key, &own) && (own.enumerable || g_show_hidden)) return NULL; + ScrDyn *tag = scr_dyn_symbol_key_get(d, key, true); + if (!tag) { + if (scr_exc_pending()) scr_exc_clear(); + return NULL; + } + ScrStr *out = tag->kind == SCR_DYN_STR && tag->v.str->len ? scr_str_retain(tag->v.str) : NULL; + scr_dyn_release(tag); + return out; +} + +/* Node's getPrefix for a plain object, without the trailing space: "", + * "Object [tag]" ("Object" for the tag "Object"), or the null-prototype + * marker followed by " [tag]". +1. */ +static ScrStr *insp_plain_object_prefix(const ScrDyn *d, const ScrStr *tag) { + InspBuf out = {0}; + if (d->null_proto) ib_cstr(&out, "[Object: null prototype]"); + else if (tag) ib_cstr(&out, "Object"); + if (tag && !(tag->len == 6 && memcmp(tag->data, "Object", 6) == 0)) { + ib_cstr(&out, " ["); + ib_bytes(&out, tag->data, tag->len); + ib_char(&out, ']'); + } + return ib_take(&out); +} + static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char *cls) { + /* A plain object with a tag takes Node's tagged prefix in all three + * forms: `Object [Math] {}`, `[Object [Math]]` past the depth limit, + * and `Object [Math] { key: value }`. */ + ScrStr *tag = cls ? NULL : insp_plain_object_tag(d); + ScrStr *tagged = tag ? insp_plain_object_prefix(d, tag) : NULL; + if (tag) scr_str_release(tag); size_t shown = 0; for (size_t i = 0; i < scr_dyn_obj_count(d); i++) { ScrDynProp ent = scr_dyn_obj_prop_at(d, i); @@ -1719,6 +1760,15 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char ScrDynProp ent; shown += scr_insp_symbol_entry(d, i, &ent); } + if (tagged && (shown == 0 || recurse > depth)) { + InspBuf out = {0}; + bool bracket = shown != 0 && !d->null_proto; + if (bracket) ib_char(&out, '['); + ib_bytes(&out, tagged->data, tagged->len); + ib_cstr(&out, bracket ? "]" : shown == 0 ? " {}" : ""); + scr_str_release(tagged); + return shown == 0 ? ib_take(&out) : insp_styled(ib_take(&out), INSP_SPECIAL); + } if (shown == 0) { if (cls) return scr_insp_class_tag(cls, " {}"); return d->null_proto ? scr_str_new("[Object: null prototype] {}", 27) @@ -1771,6 +1821,7 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char scr_str_release(entry); } ScrStr *base = cls ? scr_str_new(cls, strlen(cls)) + : tagged ? tagged : d->null_proto ? scr_str_new("[Object: null prototype]", 24) : scr_str_new("", 0); ScrStr *b0 = scr_str_new("{", 1); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index bee032446..638c2f1ba 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -261,6 +261,7 @@ void scr_dyn_install_to_string_tag_symbol(ScrDyn *key) { scr_to_string_tag_symbol = scr_dyn_retain(key); scr_atexit(scr_to_string_tag_symbol_cleanup); } +const ScrDyn *scr_dyn_to_string_tag_symbol(void) { return scr_to_string_tag_symbol; } void scr_dyn_install_iterator_symbol(ScrDyn *key) { if (scr_iterator_symbol) return; diff --git a/tests/corpus/builtin-values-untyped-slots.ts b/tests/corpus/builtin-values-untyped-slots.ts new file mode 100644 index 000000000..4185a524e --- /dev/null +++ b/tests/corpus/builtin-values-untyped-slots.ts @@ -0,0 +1,53 @@ +// Builtins read into any- and unknown-typed slots hold the values Node reads. +function take(x: unknown): any { + return x; +} +function give(): any { + return Math; +} + +const record: Record = { + json: JSON, + math: Math, + buffer: Buffer, +}; +const loose: any = { + JSON, + Reflect, + k: Map, + bytes: Uint8Array, + clone: structuredClone, +}; +const list: any[] = [JSON, Math, atob, btoa]; +const json: any = record.json; +const math: any = record.math; + +console.log(typeof json, typeof math, typeof loose.Reflect, typeof loose.clone); +console.log( + json === loose.JSON, + json === list[0], + math === list[1], + math === give(), +); +console.log( + take(JSON) === json, + take(Map) === loose.k, + take(Buffer) === record.buffer, +); + +console.log(json.stringify({ a: [1, "x"] }), json.parse("[2,3]").length); +console.log(math.max(3, 9, 4), math.PI.toFixed(5), list[1].sqrt(16)); +console.log(loose.Reflect.ownKeys({ b: 1, c: 2 })); +console.log(new loose.k([[1, "one"]]).get(1), new loose.bytes(3).length); +console.log((record.buffer as any).from("hi").toString("hex")); +console.log(loose.clone({ n: [1, 2] }).n[1], list[2]("aGk="), list[3]("hi")); +console.log(math.max.name, math.max.length, json.parse.name, loose.clone.name); + +console.log(Math, JSON, Reflect); +console.log({ nested: { math: record.math } }); +console.log(Object.prototype.toString.call(math), String(json)); +console.log(JSON.stringify({ math, json })); +console.log( + Object.keys(math).length, + Object.getOwnPropertyNames(json).join(","), +); diff --git a/tests/corpus/object-to-string-tag-inspect.mjs b/tests/corpus/object-to-string-tag-inspect.mjs new file mode 100644 index 000000000..c72f43e65 --- /dev/null +++ b/tests/corpus/object-to-string-tag-inspect.mjs @@ -0,0 +1,37 @@ +// util.inspect brackets a plain object's Symbol.toStringTag after its prefix. +const hidden = {}; +Object.defineProperty(hidden, Symbol.toStringTag, { value: "X" }); +const keyed = { a: 1 }; +Object.defineProperty(keyed, Symbol.toStringTag, { value: "Y" }); +const bare = Object.create(null); +Object.defineProperty(bare, Symbol.toStringTag, { value: "N" }); +const bareKeyed = Object.create(null); +bareKeyed.k = 1; +Object.defineProperty(bareKeyed, Symbol.toStringTag, { value: "N" }); +const empty = {}; +Object.defineProperty(empty, Symbol.toStringTag, { value: "" }); +const named = { z: 1 }; +Object.defineProperty(named, Symbol.toStringTag, { value: "Object" }); +const getter = { c: 1 }; +Object.defineProperty(getter, Symbol.toStringTag, { get: () => "G" }); +const throwing = { c: 1 }; +Object.defineProperty(throwing, Symbol.toStringTag, { + get() { + throw new Error("boom"); + }, +}); +const numeric = { c: 1 }; +Object.defineProperty(numeric, Symbol.toStringTag, { value: 7 }); +const inherited = Object.create(hidden); +const listed = { [Symbol.toStringTag]: "Z", b: 2 }; + +console.log(hidden, keyed, bare, bareKeyed); +console.log(empty, named, getter, throwing, numeric); +console.log(inherited, listed, [keyed], { keyed }); +console.log({ a: { b: { keyed, bareKeyed, named, hidden } } }); +console.log(Math, JSON, Reflect, { a: { b: { c: Math } } }); +console.log( + Object.prototype.toString.call(hidden), + String(keyed), + `${inherited}`, +); diff --git a/tests/diagnostics/builtin-values-without-lowering.ts b/tests/diagnostics/builtin-values-without-lowering.ts new file mode 100644 index 000000000..b8b5c90a1 --- /dev/null +++ b/tests/diagnostics/builtin-values-without-lowering.ts @@ -0,0 +1,11 @@ +// Builtins without a lowered value refuse at compile time in TypeScript, +// including as object literal members. +function take(x: unknown): unknown { + return x; +} + +const inferred = { json: JSON }; +const loose: any = { intl: Intl, proxy: Proxy }; +const timers: Record = { setTimeout }; +const list: any[] = [Array, parseInt]; +console.log(inferred, loose, timers, list, take(process)); diff --git a/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt b/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt new file mode 100644 index 000000000..97ee5fc49 --- /dev/null +++ b/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt @@ -0,0 +1,53 @@ +builtin-values-without-lowering.ts:7:7 - error SC2009: values of type '{ json: JSON; }' cannot be compiled: the record shape is supported, but its member 'json.parse' has type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }', which does not compile + + 6 | + 7 | const inferred = { json: JSON }; + | ^~~~~~~~ + 8 | const loose: any = { intl: Intl, proxy: Proxy }; + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +builtin-values-without-lowering.ts:7:18 - error SC2009: values of type '{ json: JSON; }' cannot be compiled: the record shape is supported, but its member 'json.parse' has type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }', which does not compile + + 6 | + 7 | const inferred = { json: JSON }; + | ^~~~~~~~~~~~~~ + 8 | const loose: any = { intl: Intl, proxy: Proxy }; + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +builtin-values-without-lowering.ts:8:28 - error SC2020: 'Intl' is part of the standard library types but has no scriptc lowering yet + + 7 | const inferred = { json: JSON }; + 8 | const loose: any = { intl: Intl, proxy: Proxy }; + | ^~~~ + 9 | const timers: Record = { setTimeout }; + + hint: native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported + +builtin-values-without-lowering.ts:9:43 - error SC2020: 'setTimeout' is part of the standard library types but has no scriptc lowering yet + + 8 | const loose: any = { intl: Intl, proxy: Proxy }; + 9 | const timers: Record = { setTimeout }; + | ^~~~~~~~~~ + 10 | const list: any[] = [Array, parseInt]; + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +builtin-values-without-lowering.ts:10:22 - error SC2020: 'Array' is part of the standard library types but has no scriptc lowering yet + + 9 | const timers: Record = { setTimeout }; + 10 | const list: any[] = [Array, parseInt]; + | ^~~~~ + 11 | console.log(inferred, loose, timers, list, take(process)); + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +builtin-values-without-lowering.ts:11:13 - error SC2004: uses of 'inferred' inherit the blocker on its declaration + + 10 | const list: any[] = [Array, parseInt]; + 11 | console.log(inferred, loose, timers, list, take(process)); + | ^~~~~~~~ + 12 | + + hint: the declaration of 'inferred' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json b/tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json new file mode 100644 index 000000000..b42466fc7 --- /dev/null +++ b/tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json @@ -0,0 +1 @@ +{ "plain": 4051, "san": 5400 } diff --git a/tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json b/tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json new file mode 100644 index 000000000..ed19ae7fb --- /dev/null +++ b/tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json @@ -0,0 +1 @@ +{ "plain": 2793, "san": 3480 } From c5f423b464cfe417ac4295807e65af17355cd1de Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:07:53 -0500 Subject: [PATCH 129/193] Decode malformed UTF-8 to U+FFFD in the ICU converter and at directory and argument boundaries The ICU unit's WTF-8 to UTF-16 conversion counted units from lead bytes alone. A 4-byte-shaped encoding of a BMP value counted two units and wrote one, so ICU received a length past the units written, and word segments built from those ranges could return prior heap contents. The conversion now decodes each maximal ill-formed subsequence to U+FFFD, as the WHATWG UTF-8 decoder does, keeps WTF-8's 3-byte lone surrogates as their unit, and reports the units it wrote. The decoder lives in scr_intl_utf16.h. Directory entry names (readdir, opendir) and process.argv entries now decode as UTF-8 with U+FFFD for malformed bytes, as Node decodes them, instead of entering strings as raw bytes. test_intl_utf16.c checks the decoder against Node's TextDecoder over malformed sequences. intl-malformed-utf8.test.ts compares a program that segments malformed directory names and an argument against Node. --- .../compiler/src/backend/native/executable.ts | 15 ++- .../corpus/intl-worker-first-use.ts.json | 6 ++ packages/runtime/src/scr_intl.c | 97 +++++++------------ packages/runtime/src/scr_intl_utf16.h | 82 ++++++++++++++++ packages/runtime/src/scr_lib.c | 13 ++- packages/runtime/test/intl-utf16.test.ts | 75 ++++++++++++++ packages/runtime/test/test_intl_utf16.c | 33 +++++++ tests/corpus/intl-worker-first-use.ts | 36 +++++++ .../intl-worker-first-use.ts.json | 1 + tests/harness/intl-malformed-utf8.test.ts | 73 ++++++++++++++ 10 files changed, 360 insertions(+), 71 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-worker-first-use.ts.json create mode 100644 packages/runtime/src/scr_intl_utf16.h create mode 100644 packages/runtime/test/intl-utf16.test.ts create mode 100644 packages/runtime/test/test_intl_utf16.c create mode 100644 tests/corpus/intl-worker-first-use.ts create mode 100644 tests/harness/corpus-costs/intl-worker-first-use.ts.json create mode 100644 tests/harness/intl-malformed-utf8.test.ts diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index c4750ba06..f797eec9a 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -718,7 +718,17 @@ export async function compileCInternal( let icuArchive = icu ? (icuPack?.archive ?? icuArchivePath(driver, vendorBuildIdentity, vendorCacheRoot)) : null; - let icuIncludeDirs: readonly string[] = icuPack === null ? [] : [icuPack.include]; + // scr_intl.c's ICU headers. They are known before the runtime-object flags + // are: those flags compile every runtime unit, so without them a cached + // scr_intl.o would take whatever ICU headers the host has. + const icuSource = + icu && icuPack === null ? join(await ensureIcuSources(vendorCacheRoot), "source") : null; + const icuIncludeDirs: readonly string[] = + icuPack !== null + ? [icuPack.include] + : icuSource !== null + ? [join(icuSource, "common"), join(icuSource, "i18n")] + : []; // ours would collide; see scr_regex.c) — the standalone objects are for // static builds only. @@ -784,8 +794,6 @@ export async function compileCInternal( ? await materialize() : await stageVendorInputs(materialize, join(stageRoot, "icu")); icuArchive = paths[0]!; - const icuSource = join(await ensureIcuSources(materializeCacheRoot), "source"); - icuIncludeDirs = [join(icuSource, "common"), join(icuSource, "i18n")]; } if (regex) { for (const object of lreObjectPaths( @@ -924,6 +932,7 @@ export async function compileCInternal( ...(regex ? ["-I", vendorEngineDir()] : []), ...(zlibObjects.length > 0 ? ["-I", vendorZlibDir()] : []), ...(tlsArchive !== null ? ["-I", join(vendorTlsDir(), "include")] : []), + ...icuIncludeDirs.flatMap((dir) => ["-I", dir]), ]; // Keep sanitizer runtime objects optimized in both postures. Large dev // programs still compile at O0, avoiding an expensive LLVM optimization diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-worker-first-use.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-worker-first-use.ts.json new file mode 100644 index 000000000..b5a08b90a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-worker-first-use.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-worker-first-use.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_intl.c b/packages/runtime/src/scr_intl.c index cbe160e88..28264f3b5 100644 --- a/packages/runtime/src/scr_intl.c +++ b/packages/runtime/src/scr_intl.c @@ -23,6 +23,7 @@ * That is what lets the zero-cost gate assert that a non-Intl binary links * no ICU symbol at all. */ #include "scr_runtime.h" +#include "scr_intl_utf16.h" #include "scr_number.h" #include @@ -36,6 +37,7 @@ #include #include +#include #include #include #include @@ -51,9 +53,12 @@ /* ── data ───────────────────────────────────────────────────────────── * ICU keeps the mapped data alive for the process lifetime, so the load - * happens exactly once and never unmaps. */ + * happens exactly once and never unmaps. Worker threads can make the first + * ICU call at the same time: pthread_once runs the load on one of them and + * holds the others until it has finished, so none reads a partial result. */ -static int scr_intl_data_state; /* 0 = not tried, 1 = ready, 2 = failed */ +static pthread_once_t scr_intl_data_once = PTHREAD_ONCE_INIT; +static bool scr_intl_data_ready; /* The default locale every formatter and the default word segmenter use, * resolved with the data (scr_intl_default_locale). */ @@ -117,12 +122,10 @@ static void scr_intl_default_locale(void) { snprintf(scr_intl_locale, sizeof scr_intl_locale, "%s", name); } -/** True once ICU can resolve data: the file beside the executable, else - * whatever ICU's own search finds (ICU_DATA). Never throws; the callers - * do, with the same message. */ -static bool scr_intl_data(void) { - if (scr_intl_data_state != 0) return scr_intl_data_state == 1; - scr_intl_data_state = 2; +/** Points ICU at its data (the file beside the executable, else whatever + * ICU's own search finds, ICU_DATA), probes it and resolves the default + * locale. Runs once per process (scr_intl_data). */ +static void scr_intl_data_load(void) { char dir[PATH_MAX]; if (scr_intl_exe_dir(dir, sizeof dir)) { char path[PATH_MAX]; @@ -136,11 +139,17 @@ static bool scr_intl_data(void) { * Everything else this unit formats resolves through the same data. */ UErrorCode status = U_ZERO_ERROR; UNumberFormat *probe = unum_open(UNUM_DECIMAL, NULL, 0, NULL, NULL, &status); - if (U_FAILURE(status) || probe == NULL) return false; + if (U_FAILURE(status) || probe == NULL) return; unum_close(probe); scr_intl_default_locale(); - scr_intl_data_state = 1; - return true; + scr_intl_data_ready = true; +} + +/** True once ICU can resolve data. Never throws; the callers do, with the + * same message. */ +static bool scr_intl_data(void) { + (void)pthread_once(&scr_intl_data_once, scr_intl_data_load); + return scr_intl_data_ready; } /** Throw the missing-data Error (Node cannot reach this: its ICU data is @@ -155,62 +164,22 @@ static void scr_intl_no_data(void) { /* ── WTF-8 ↔ UTF-16 ─────────────────────────────────────────────────── * ScrStr storage is canonical WTF-8 (a lone surrogate is its 3-byte * generalized form), so both directions preserve lone surrogates and - * every public offset stays a UTF-16 unit count. */ + * every public offset stays a UTF-16 unit count. Bytes that are not WTF-8 + * decode to U+FFFD (scr_intl_utf16.h), so the UTF-16 copy holds exactly the + * units it reports. */ -static int32_t scr_intl_units(const char *data, size_t len) { - int32_t units = 0; - for (size_t at = 0; at < len;) { - unsigned char byte = (unsigned char)data[at]; - if (byte < 0x80) at += 1; - else if (byte < 0xe0) at += 2; - else if (byte < 0xf0) at += 3; - else { - at += 4; - units++; /* a non-BMP code point is two units */ - } - units++; - } - return units; -} - -/** A UTF-16 copy of `s`, NUL-terminated; *units receives its length. - * Never NULL for a successful call (an empty string allocates one unit). */ +/** A UTF-16 copy of `s`, NUL-terminated; *units receives the number of + * units written. Never NULL for a successful call (an empty string + * allocates one unit). */ static UChar *scr_intl_to_utf16(const ScrStr *s, int32_t *units) { - const char *data = s->data; - size_t len = s->len; - int32_t count = scr_intl_units(data, len); - UChar *text = malloc(((size_t)count + 1) * sizeof *text); + const unsigned char *data = (const unsigned char *)s->data; + size_t count = scr_intl_utf16_units(data, s->len); + if (count > INT32_MAX - 1) scr_trap("scriptc: string too long for Intl\n"); + UChar *text = malloc((count + 1) * sizeof *text); if (text == NULL) scr_trap("scriptc: out of memory\n"); - int32_t at = 0; - for (size_t pos = 0; pos < len;) { - unsigned char byte = (unsigned char)data[pos]; - uint32_t cp; - size_t size; - if (byte < 0x80) { - cp = byte; - size = 1; - } else if (byte < 0xe0) { - cp = ((uint32_t)(byte & 0x1f) << 6) | ((unsigned char)data[pos + 1] & 0x3fu); - size = 2; - } else if (byte < 0xf0) { - cp = ((uint32_t)(byte & 0x0f) << 12) | (((unsigned char)data[pos + 1] & 0x3fu) << 6) | - ((unsigned char)data[pos + 2] & 0x3fu); - size = 3; - } else { - cp = ((uint32_t)(byte & 0x07) << 18) | (((unsigned char)data[pos + 1] & 0x3fu) << 12) | - (((unsigned char)data[pos + 2] & 0x3fu) << 6) | ((unsigned char)data[pos + 3] & 0x3fu); - size = 4; - } - if (cp > 0xffff) { - text[at++] = (UChar)(0xd800 + ((cp - 0x10000) >> 10)); - text[at++] = (UChar)(0xdc00 + ((cp - 0x10000) & 0x3ff)); - } else { - text[at++] = (UChar)cp; - } - pos += size; - } - text[at] = 0; - *units = count; + size_t written = scr_intl_utf16_fill(data, s->len, (uint16_t *)text); + text[written] = 0; + *units = (int32_t)written; return text; } diff --git a/packages/runtime/src/scr_intl_utf16.h b/packages/runtime/src/scr_intl_utf16.h new file mode 100644 index 000000000..3ab368146 --- /dev/null +++ b/packages/runtime/src/scr_intl_utf16.h @@ -0,0 +1,82 @@ +/* scriptc runtime — WTF-8 to UTF-16 for the ICU unit (scr_intl.c). Kept + * apart from ICU's headers so packages/runtime/test can check the decode + * on its own (test_intl_utf16.c). */ +#ifndef SCR_INTL_UTF16_H +#define SCR_INTL_UTF16_H + +#include +#include + +/** The code point of the WTF-8 sequence at `p` (before `end`), and its byte + * count in *size. A malformed sequence (an overlong form, a value above + * U+10FFFF, a stray or missing continuation byte) is decoded one maximal + * subpart at a time to U+FFFD, as the WHATWG UTF-8 decoder does. The 3-byte + * form of a surrogate is WTF-8's lone surrogate and decodes to that unit. */ +static inline uint32_t scr_intl_decode(const unsigned char *p, const unsigned char *end, size_t *size) { + unsigned char lead = p[0]; + *size = 1; + if (lead < 0x80) return lead; + size_t need; + unsigned char lo = 0x80, hi = 0xbf; + uint32_t cp; + if (lead >= 0xc2 && lead <= 0xdf) { + need = 1; + cp = lead & 0x1fu; + } else if (lead >= 0xe0 && lead <= 0xef) { + need = 2; + cp = lead & 0x0fu; + if (lead == 0xe0) lo = 0xa0; + } else if (lead >= 0xf0 && lead <= 0xf4) { + need = 3; + cp = lead & 0x07u; + if (lead == 0xf0) lo = 0x90; + if (lead == 0xf4) hi = 0x8f; + } else { + return 0xfffd; + } + for (size_t i = 1; i <= need; i++) { + if (p + i >= end || p[i] < lo || p[i] > hi) { + *size = i; + return 0xfffd; + } + cp = (cp << 6) | (p[i] & 0x3fu); + lo = 0x80; + hi = 0xbf; + } + *size = need + 1; + return cp; +} + +/** The number of UTF-16 units the `len` bytes at `data` decode to. */ +static inline size_t scr_intl_utf16_units(const unsigned char *data, size_t len) { + const unsigned char *end = data + len; + size_t count = 0; + for (const unsigned char *p = data; p < end;) { + size_t size; + count += scr_intl_decode(p, end, &size) > 0xffff ? 2 : 1; + p += size; + } + return count; +} + +/** Writes the UTF-16 units of the `len` bytes at `data` to `out`, which has + * room for scr_intl_utf16_units(data, len) of them, and returns how many it + * wrote: always that count. */ +static inline size_t scr_intl_utf16_fill(const unsigned char *data, size_t len, uint16_t *out) { + const unsigned char *end = data + len; + size_t at = 0; + for (const unsigned char *p = data; p < end;) { + size_t size; + uint32_t cp = scr_intl_decode(p, end, &size); + if (cp > 0xffff) { + out[at++] = (uint16_t)(0xd800 + ((cp - 0x10000) >> 10)); + out[at++] = (uint16_t)(0xdc00 + ((cp - 0x10000) & 0x3ff)); + } else { + out[at++] = (uint16_t)cp; + } + p += size; + } + return at; +} + +#endif /* SCR_INTL_UTF16_H */ diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..a46868b20 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -626,9 +626,11 @@ ScrArr *scr_process_argv(void) { int argc = scr_lib_arg_count(); scr_argv_arr = scr_arr_new(SCR_ELEM_STR, (size_t)argc + 1); scr_arr_push_ref(scr_argv_arr, scr_str_new("scriptc", 7)); + /* Node decodes each argument as UTF-8, replacing malformed bytes with + * U+FFFD. */ for (int i = 0; i < argc; i++) { const char *a = scr_lib_arg(i); - scr_arr_push_ref(scr_argv_arr, scr_str_new(a, strlen(a))); + scr_arr_push_ref(scr_argv_arr, scr_str_from_utf8_lossy((const uint8_t *)a, strlen(a))); } } return scr_arr_retain(scr_argv_arr); @@ -2828,7 +2830,7 @@ void scr_process_set_title(const ScrStr *title) { ScrStr *scr_process_argv0(void) { if (scr_lib_arg_count() < 1) return scr_str_new("", 0); const char *a = scr_lib_arg(0); - return scr_str_new(a, strlen(a)); + return scr_str_from_utf8_lossy((const uint8_t *)a, strlen(a)); } static SCR_TL ScrDyn *scr_release_object; @@ -6650,7 +6652,8 @@ ScrScandir *scr_fs_scandir(ScrStr *path) { scr_trap("scriptc: out of memory\n"); } } - s->names[s->len] = scr_str_new(ent->d_name, strlen(ent->d_name)); + /* Decoded as UTF-8 with U+FFFD for malformed bytes, as Node does. */ + s->names[s->len] = scr_str_from_utf8_lossy((const uint8_t *)ent->d_name, strlen(ent->d_name)); s->kinds[s->len] = kind; s->len++; } @@ -6896,7 +6899,9 @@ bool scr_dir_read(ScrDir *d, ScrStr **name, double *kind) { return false; } if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) continue; - *name = scr_str_new(ent->d_name, strlen(ent->d_name)); + /* Node decodes an entry name as UTF-8, replacing malformed bytes with + * U+FFFD. */ + *name = scr_str_from_utf8_lossy((const uint8_t *)ent->d_name, strlen(ent->d_name)); *kind = scr_dir_entry_kind(d, ent); return true; } diff --git a/packages/runtime/test/intl-utf16.test.ts b/packages/runtime/test/intl-utf16.test.ts new file mode 100644 index 000000000..7c4b564a0 --- /dev/null +++ b/packages/runtime/test/intl-utf16.test.ts @@ -0,0 +1,75 @@ +import { execFile, execFileSync } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const testDir = import.meta.dirname; +const bin = join(testDir, "build", "test_intl_utf16"); + +// The ICU unit's WTF-8 decode (scr_intl_utf16.h), built with ASan so a +// unit written past the count it reports fails the run. +beforeAll(async () => { + await mkdir(join(testDir, "build"), { recursive: true }); + await execFileAsync("clang", [ + "-std=c11", + "-O1", + "-Wall", + "-Wextra", + "-fsanitize=address", + "-I", + join(testDir, "../src"), + "-o", + bin, + join(testDir, "test_intl_utf16.c"), + ]); +}, 120_000); + +const CASES: number[][] = [ + [0x61, 0x62], + [0xc3, 0xa9, 0xe2, 0x82, 0xac, 0xf0, 0x9f, 0x98, 0x80], + [0x61, 0xff, 0x62], + [0xc0, 0xaf], + [0xc1, 0xbf, 0x41], + [0xe0, 0x80, 0x80], + [0xe0, 0x9f, 0xbf], + [0xf0, 0x80, 0x80, 0x41], + [0xf0, 0x8f, 0xbf, 0xbf], + [0xf4, 0x90, 0x80, 0x80], + [0xf5, 0x80, 0x80, 0x80], + [0x80, 0xbf, 0x20], + [0xe2, 0x82], + [0xe2, 0x82, 0x41], + [0xf0, 0x9f, 0x98], + [0xf0, 0x9f], + [0xc3], + [0x74, 0x72, 0x75, 0x6e, 0x63, 0xe2, 0x82], + [0x67, 0x6f, 0xff, 0xe0, 0x80, 0x77, 0x6f], +]; + +/** A WTF-8 lone surrogate (ED A0..BF xx) decodes to its unit, and its + * truncated prefix is one maximal subpart; every other sequence decodes as + * Node's TextDecoder decodes it. */ +const SURROGATES: [number[], string][] = [ + [[0x73, 0xed, 0xa0, 0x80, 0x78], "0073 d800 0078"], + [[0xed, 0xbf, 0xbf], "dfff"], + [[0xed, 0xa0], "fffd"], +]; + +function hexUnits(text: string): string { + return [...Array(text.length).keys()] + .map((i) => text.charCodeAt(i).toString(16).padStart(4, "0")) + .join(" "); +} + +test("malformed sequences decode to U+FFFD as Node's UTF-8 decoder does, and the count is the units written", () => { + const all = [...CASES, ...SURROGATES.map(([bytes]) => bytes)]; + const input = all.map((bytes) => Buffer.from(bytes).toString("hex")).join("\n") + "\n"; + const lines = execFileSync(bin, [], { input, encoding: "utf8" }).trimEnd().split("\n"); + const decoder = new TextDecoder(); + expect(lines).toEqual([ + ...CASES.map((bytes) => hexUnits(decoder.decode(Uint8Array.from(bytes)))), + ...SURROGATES.map(([, units]) => units), + ]); +}); diff --git a/packages/runtime/test/test_intl_utf16.c b/packages/runtime/test/test_intl_utf16.c new file mode 100644 index 000000000..08f88ef73 --- /dev/null +++ b/packages/runtime/test/test_intl_utf16.c @@ -0,0 +1,33 @@ +/* scr_intl_utf16.h over byte sequences from stdin, one per line as hex + * (`61ff62`). Prints each sequence's UTF-16 units as hex, space-separated, + * and exits 1 when the count and the units written disagree. */ +#include "scr_intl_utf16.h" + +#include +#include +#include + +int main(void) { + char line[4096]; + while (fgets(line, sizeof line, stdin) != NULL) { + unsigned char bytes[2048]; + size_t len = 0; + for (const char *p = line; p[0] != '\0' && p[0] != '\n' && p[1] != '\0'; p += 2) { + unsigned int byte; + if (sscanf(p, "%2x", &byte) != 1) return 2; + bytes[len++] = (unsigned char)byte; + } + size_t count = scr_intl_utf16_units(bytes, len); + uint16_t *units = malloc((count + 1) * sizeof *units); + if (units == NULL) return 2; + size_t written = scr_intl_utf16_fill(bytes, len, units); + if (written != count) { + printf("count %zu written %zu\n", count, written); + return 1; + } + for (size_t i = 0; i < written; i++) printf(i == 0 ? "%04x" : " %04x", units[i]); + printf("\n"); + free(units); + } + return 0; +} diff --git a/tests/corpus/intl-worker-first-use.ts b/tests/corpus/intl-worker-first-use.ts new file mode 100644 index 000000000..8e8621ac5 --- /dev/null +++ b/tests/corpus/intl-worker-first-use.ts @@ -0,0 +1,36 @@ +// @requires-icu +// @env: TZ=UTC,LC_ALL=en_US.UTF-8 +// The program's first ICU calls come from eight worker threads at once: +// each worker formats a number and a date as its first Intl use. Every +// worker answers, and none reports the ICU data as missing while another +// thread is still loading it. +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +if (isMainThread) { + const results: string[] = []; + let pending = 8; + for (let i = 0; i < 8; i++) { + const worker = new Worker(new URL(import.meta.url), { workerData: i }); + worker.on("message", (text: string) => { + results.push(text); + }); + worker.on("exit", () => { + pending--; + if (pending === 0) { + results.sort(); + for (const text of results) console.log(text); + } + }); + } +} else { + const index = workerData as number; + let text: string; + try { + const number = (1234567.5 + index).toLocaleString(); + const date = new Date(Date.UTC(2024, 0, 1 + index, 9, 30)).toLocaleString(); + text = `${index} ${number} ${date}`; + } catch (error) { + text = `${index} threw ${error instanceof Error ? error.message : String(error)}`; + } + parentPort!.postMessage(text); +} diff --git a/tests/harness/corpus-costs/intl-worker-first-use.ts.json b/tests/harness/corpus-costs/intl-worker-first-use.ts.json new file mode 100644 index 000000000..29984d425 --- /dev/null +++ b/tests/harness/corpus-costs/intl-worker-first-use.ts.json @@ -0,0 +1 @@ +{ "plain": 4393, "san": 189179 } diff --git a/tests/harness/intl-malformed-utf8.test.ts b/tests/harness/intl-malformed-utf8.test.ts new file mode 100644 index 000000000..2b6abaddd --- /dev/null +++ b/tests/harness/intl-malformed-utf8.test.ts @@ -0,0 +1,73 @@ +/* Strings that come from bytes the OS hands over (directory entry names, + * command-line arguments) decode as UTF-8 with U+FFFD for each malformed + * subsequence, as Node decodes them, and a word segmenter over them matches + * Node: overlong forms, truncated sequences, stray continuation bytes and + * UTF-8-encoded surrogates included. */ +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); + +const PROGRAM = ` +import { readdirSync } from "node:fs"; +const words = new Intl.Segmenter(undefined, { granularity: "word" }); +function show(label: string, text: string): void { + const parts = [...words.segment(text)].map((part) => JSON.stringify(part.segment) + "@" + part.index); + console.log(label, JSON.stringify(text), text.length, parts.join(" ")); +} +const names = readdirSync(process.argv[2]!).sort(); +for (const name of names) show("name", name); +show("arg", process.argv[3]!); +`; + +const NAMES = [ + [0x61, 0xff, 0x62, 0x2e, 0x74, 0x78, 0x74], + [0x6f, 0x76, 0x65, 0x72, 0xf0, 0x80, 0x80, 0x41, 0x6c, 0x6f, 0x6e, 0x67], + [0x74, 0x72, 0x75, 0x6e, 0x63, 0xe2, 0x82], + [0x73, 0x75, 0x72, 0xed, 0xa0, 0x80, 0x78], + [0x6f, 0x6b, 0x2d, 0xc3, 0xa9, 0x2e, 0x74, 0x78, 0x74], + [0x63, 0x6f, 0x6e, 0x74, 0x80, 0xbf, 0x20, 0x77, 0x6f, 0x72, 0x64], +]; + +test.skipIf(process.platform !== "linux" || process.arch !== "x64")( + "malformed UTF-8 from directory entries and arguments segments as in Node", + async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-intl-malformed-")); + try { + const entry = join(dir, "main.ts"); + writeFileSync(entry, PROGRAM); + const files = join(dir, "files"); + mkdirSync(files); + for (const name of NAMES) + writeFileSync(Buffer.concat([Buffer.from(files + "/"), Buffer.from(name)]), ""); + const result = await compile(entry, { + outPath: join(dir, "out", "main"), + outDir: join(dir, "out"), + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + // The argument's bytes are not valid UTF-8, so the shell passes them. + const run = (argv0: string[]) => + execFileAsync( + "/bin/sh", + ["-c", `"$@" "$(printf 'go\\377\\340\\200wo rd')"`, "sh", ...argv0, files], + { + env: { ...process.env, LC_ALL: "en_US.UTF-8" }, + }, + ); + const node = await run([process.execPath, entry]); + const native = await run([result.binaryPath]); + expect(native.stdout).toBe(node.stdout); + expect(node.stdout).toContain("�"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + 300_000, +); From 68ec61fe227522eb8dc0a4d5f7324925e6404ff5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:15:44 -0500 Subject: [PATCH 130/193] Replay jsval entry thunks in chunked emission and rename the profile's ABI marker Chunked emission replays every numbered helper a range creates on the merging emitter. The jsval entry thunks (dyn.jsEntryThunk) were missing from the replayed dyn helpers, so a range that boxed a closure with a jsval body failed to merge. The committed runtime profile named the runtime ABI marker scr_runtime_abi_v47, which ABI 48 renames to scr_runtime_abi_v48. The profile's record now carries the new name; its hash and counts are unchanged. --- packages/compiler/src/backend/llvm/emitter.ts | 1 + .../profiles/linux-x64-gnu.profdata.gz | Bin 89083 -> 83196 bytes 2 files changed, 1 insertion(+) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a6848fa05..34cd953c8 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -8585,6 +8585,7 @@ for (const op of [ "dyn.promiseDynAdapterHelper", "dyn.dynFuncThunkHelper", "dyn.dynFuncBoxHelper", + "dyn.jsEntryThunk", "dyn.dynFuncAdapterHelper", "dyn.dynToStrHelper", "dyn.dynIterNHelper", diff --git a/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz b/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz index 3bc3ecc28448262364cb5647b5e2019301ffb1ea..c42d816483408f9224f5d0855c7daa13d5d421c7 100644 GIT binary patch literal 83196 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ΠjJTG_T-Hf!z8i-zt0VF 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 literal 89083 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%0A;tyJF_a}%exuJ`+@SFw4g(0 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

J8L7_-dO3In!%~hY};4g_oC5uCxS*DyYooDPX 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 From 352842f020e0bc6b8b058c9278b018b846784fc9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:37:25 -0500 Subject: [PATCH 131/193] Scope early errors to function bodies and class strictness, and match the upstream compareArray reads - A function body or class static block treats its top-level function declarations as var-scoped: duplicates are legal in strict code, and a let/const/class of the same name is SC0006. - Strictness is the code around a node: a function declaration's own "use strict" no longer makes its declaring context strict. - Every part of a class is strict; object literal methods and accessors are strict only by their own prologue or their surroundings, and an arrow function's block body can carry "use strict". - The bare compareArray reads b.length, then a.length, and a.length again before every element pair, converting it as the upstream `i < a.length` does, so a string length compares its elements. - Tests that relied on the old handling of undeclared names now use constructs that are still refused or still compile: the installed-entry diagnostics fixture reports a syntax error (a read of an undeclared name is now Node's run-time ReferenceError), and static-objects-order's loops.js stores from its for-in head into `undefined` instead of an undeclared name (a store into an undeclared name refuses its package with SC1091). --- .../src/frontend/early-errors.test.ts | 43 ++++++++++++ .../compiler/src/frontend/early-errors.ts | 66 ++++++++++++------- .../static-objects-order/loops.js | 10 +-- tests/harness/installed-entry.test.ts | 4 +- tests/harness/static-objects-order.test.ts | 6 +- tests/harness/test262.test.ts | 17 +++++ tests/test262/harness.ts | 14 ++-- 7 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/src/frontend/early-errors.test.ts diff --git a/packages/compiler/src/frontend/early-errors.test.ts b/packages/compiler/src/frontend/early-errors.test.ts new file mode 100644 index 000000000..718294a48 --- /dev/null +++ b/packages/compiler/src/frontend/early-errors.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { checkPreflight, loadProgram } from "./program-node.js"; + +/** The preflight diagnostics of a CommonJS script (sloppy unless it says + * "use strict"). */ +function diagnostics(source: string) { + const directory = mkdtempSync(join(tmpdir(), "scriptc-early-errors-")); + writeFileSync(join(directory, "main.cjs"), source); + const load = loadProgram(join(directory, "main.cjs")); + try { + return checkPreflight(load).map((diagnostic) => diagnostic.code); + } finally { + load.dispose(); + rmSync(directory, { recursive: true, force: true }); + } +} + +test.for([ + // A function body's top-level function declarations are var-scoped, so + // duplicates are legal in strict code too. + 'function outer() { "use strict"; function d() {} function d() {} }\nouter();', + "class C { static { function s() {} function s() {} } }\nnew C();", + // Object literal methods and accessors are sloppy unless they say otherwise. + "var o = { m() { if (true) function f() {} } };\no.m();", + "var o = { get g() { { function h() {} function h() {} } return 1; } };\no.g;", + // A function's own directive makes its body strict, not the code declaring it. + 'if (true) function k() { "use strict"; }', + '{ function z() { "use strict"; } function z() {} }', +])("Node accepts: %s", (source) => { + expect(diagnostics(source)).toEqual([]); +}); + +test.for([ + ["function outer() { let d; function d() {} }\nouter();", "SC0006"], + ['(() => { "use strict"; if (true) function f() {} })();', "SC0007"], + ["class C { m() { if (true) function f() {} } }\nnew C().m();", "SC0007"], + ['var o = { m() { "use strict"; { function h() {} function h() {} } } };\no.m();', "SC0005"], +])("Node refuses: %s", ([source, code]) => { + expect(diagnostics(source!)).toContain(code); +}); diff --git a/packages/compiler/src/frontend/early-errors.ts b/packages/compiler/src/frontend/early-errors.ts index 37dd71b48..e37112115 100644 --- a/packages/compiler/src/frontend/early-errors.ts +++ b/packages/compiler/src/frontend/early-errors.ts @@ -92,7 +92,13 @@ type EarlyErrorCode = (typeof EARLY_ERROR_CODES)[number]; * reach into nested blocks (a `var` anywhere below the block belongs to it) * but never into a function or class body. The duplicate rule's one carve-out * is Annex B's B.3.3.1: two plain function declarations of the same name in - * the same block are legal in non-strict code. */ + * the same block are legal in non-strict code. + * + * A function body and a class static block have the same two rules over + * their top-level declarations (FunctionStatementList and + * ClassStaticBlockStatementList), but there a function declaration is + * var-scoped: it is one of the VarDeclaredNames, so two of them are legal in + * strict code too, while `let f; function f() {}` still collides. */ function checkStatementListScope( sf: ts.SourceFile, @@ -107,10 +113,11 @@ function checkStatementListScope( } if (statements === null) return; if (statements.length < 2) return; + const functionBody = isFunctionBody(node); const lexical = new Map(); for (const stmt of statements) { - for (const [name, decl] of lexicalDeclarationsOf(stmt)) { + for (const [name, decl] of lexicalDeclarationsOf(stmt, functionBody)) { const list = lexical.get(name); if (list === undefined) lexical.set(name, [decl]); else list.push(decl); @@ -119,7 +126,11 @@ function checkStatementListScope( if (lexical.size === 0) return; const varNames = new Set(); - for (const stmt of statements) collectVarDeclaredNames(stmt, varNames); + for (const stmt of statements) { + collectVarDeclaredNames(stmt, varNames); + if (functionBody && ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) + varNames.add(stmt.name.text); + } for (const [name, decls] of lexical) { if (varNames.has(name)) { @@ -127,7 +138,9 @@ function checkStatementListScope( diags, LEXICAL_VAR_COLLISION, decls[0]!, - `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`, + functionBody + ? `the lexical declaration '${name}' collides with a 'var' or function declaration of the same name in this function body` + : `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`, ); continue; } @@ -146,10 +159,22 @@ function checkStatementListScope( } } +/** Whether a block is the body of a function or a class static block, whose + * top-level function declarations are var-scoped. */ +function isFunctionBody(node: ts.Node): boolean { + const parent = node.parent; + if (parent === undefined || !ts.isBlock(node)) return false; + if (ts.isClassStaticBlockDeclaration(parent)) return true; + return ts.isFunctionLike(parent) && (parent as { body?: ts.Node }).body === node; +} + /** The names a DIRECT statement of a statement list declares lexically - * (let/const/using, class, and every function declaration form), paired with - * the node to point the diagnostic at. */ -function lexicalDeclarationsOf(stmt: ts.Statement): Array<[string, ts.Node]> { + * (let/const/using, class, and every function declaration form outside a + * function body), paired with the node to point the diagnostic at. */ +function lexicalDeclarationsOf( + stmt: ts.Statement, + functionBody: boolean, +): Array<[string, ts.Node]> { const names: Array<[string, ts.Node]> = []; if (ts.isVariableStatement(stmt)) { if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) return names; @@ -157,7 +182,7 @@ function lexicalDeclarationsOf(stmt: ts.Statement): Array<[string, ts.Node]> { collectBindingNames(decl.name, names); return names; } - if (ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt)) { + if (ts.isClassDeclaration(stmt) || (ts.isFunctionDeclaration(stmt) && !functionBody)) { const name = stmt.name; if (name !== undefined && ts.isIdentifier(name)) names.push([name.text, stmt]); } @@ -322,28 +347,23 @@ function labelledFunctionOf(stmt: ts.Statement): ts.FunctionDeclaration | null { /* ── strictness of the code around a node ─────────────────────────────── * * A directive prologue's "use strict" applies to the function (or script) it - * opens, arrows cannot carry one, and a class body is strict by - * construction. Module sources are strict whether they say so or not, and - * Node's classification — not the checker's forced external-module scope — - * decides which sources are modules. */ + * opens, an arrow function's block body included, and every part of a class is strict by + * construction. Object literal methods and accessors are ordinary functions: + * strict only by their own prologue or their surroundings. Module sources are + * strict whether they say so or not, and Node's classification — not the + * checker's forced external-module scope — decides which sources are modules. + * + * The walk starts at the node's parent: a function declaration's own "use + * strict" makes its body strict, not the code that declares it. */ function isStrictCode( sf: ts.SourceFile, node: ts.Node, isModuleCode: (sf: ts.SourceFile) => boolean, ): boolean { if (isModuleCode(sf)) return true; - for (let n: ts.Node | undefined = node; n !== undefined; n = n.parent) { + for (let n: ts.Node | undefined = node.parent; n !== undefined; n = n.parent) { if (ts.isSourceFile(n)) return hasUseStrictPrologue(n.statements.nodes); - if ( - ts.isClassStaticBlockDeclaration(n) || - ts.isMethodDeclaration(n) || - ts.isConstructorDeclaration(n) || - ts.isGetAccessorDeclaration(n) || - ts.isSetAccessorDeclaration(n) || - ts.isPropertyDeclaration(n) - ) - return true; - if (ts.isArrowFunction(n)) continue; + if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return true; if (ts.isFunctionLike(n)) { const body = n.body; if (body !== undefined && ts.isBlock(body) && hasUseStrictPrologue(body.statements.nodes)) diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js index ef5c2565b..8b759735e 100644 --- a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js @@ -1,13 +1,15 @@ // Methods that store from a for-in or for-of head where sloppy mode and -// strict mode differ: into an undeclared name (sloppy mode creates a -// global) and into a property of a string (sloppy mode drops the store). +// strict mode differ: into the read-only global `undefined` and into a +// property of a string (sloppy mode drops both stores; strict mode throws). +// A store into an undeclared name is refused for the whole package (SC1091), +// so it cannot appear here. var shapes = {}; shapes.Bag = function (size) { this.size = size; }; shapes.Bag.prototype.lastKey = function () { - for (bagLastKey in { first: 1, last: 2 }); - return bagLastKey + " " + this.size; + for (undefined in { first: 1, last: 2 }); + return typeof undefined + " " + this.size; }; shapes.Cell = function (label) { this.label = label; diff --git a/tests/harness/installed-entry.test.ts b/tests/harness/installed-entry.test.ts index 0bf9928a4..2c7473941 100644 --- a/tests/harness/installed-entry.test.ts +++ b/tests/harness/installed-entry.test.ts @@ -132,7 +132,9 @@ test("explicit installed JavaScript entry keeps diagnostics in its own modules", mkdirSync(dirname(entry), { recursive: true }); writeFileSync(join(dirname(entry), "package.json"), '{"type":"module"}\n'); writeFileSync(entry, 'import { value } from "./value.js"; console.log(value);\n'); - writeFileSync(join(dirname(entry), "value.js"), "export const value = missingName;\n"); + // A syntax error: a read of an undeclared name is Node's run-time + // ReferenceError in JavaScript, not a diagnostic. + writeFileSync(join(dirname(entry), "value.js"), "export const value = ;\n"); const { coverage } = analyze(entry); expect(coverage.preflightFailed).toBe(true); expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC0001"]); diff --git a/tests/harness/static-objects-order.test.ts b/tests/harness/static-objects-order.test.ts index 208048f18..bb68a0908 100644 --- a/tests/harness/static-objects-order.test.ts +++ b/tests/harness/static-objects-order.test.ts @@ -104,9 +104,9 @@ test( expect(rewrittenIfAny(debug, join(pkg, "coerce.js")) ?? "").not.toContain( "class __ns_shapes_Box", ); - // loops.js's methods store from for-in and for-of heads into an - // undeclared name and into a property of a string, which throws in a - // class body. + // loops.js's methods store from for-in and for-of heads into the + // read-only global `undefined` and into a property of a string, which + // throws in a class body. const loops = rewrittenIfAny(debug, join(pkg, "loops.js")) ?? ""; expect(loops).not.toContain("class __ns_shapes_Bag"); expect(loops).not.toContain("class __ns_shapes_Cell"); diff --git a/tests/harness/test262.test.ts b/tests/harness/test262.test.ts index 948c016bb..72dad6531 100644 --- a/tests/harness/test262.test.ts +++ b/tests/harness/test262.test.ts @@ -204,6 +204,23 @@ assert.notSameValue(1, "1"); status: "pass", source: "assert(compareArray(1, 2)); assert(compareArray({}, {}));", }, + { + name: "bare compareArray compares the elements of a string length", + status: "pass", + source: + 'assert(!compareArray({ length: "2", 0: 1, 1: 2 }, { length: "2", 0: 1, 1: 3 })); assert(compareArray({ length: "1", 0: 1 }, { length: "1", 0: 1 }));', + }, + { + name: "bare compareArray reads the lengths in the upstream order", + status: "pass", + source: ` +var log = []; +var a = { get length() { log.push("a"); return 1; }, 0: 1 }; +var b = { get length() { log.push("b"); return 1; }, 0: 1 }; +assert(compareArray(a, b)); +assert.compareArray(log, ["b", "a", "a", "a"]); +`, + }, { name: "bare compareArray does not throw on a mismatch", status: "fail", diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index 8a0f7bd1a..40e09734a 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -70,13 +70,15 @@ function elementAt(value: unknown, index: number): unknown { /** The bare `compareArray(a, b)` the upstream `harness/assert.js` defines * alongside `assert`. It RETURNS a boolean (the throwing assertion is - * `assert.compareArray`), and it has no shape checks of its own: it reads - * `.length` off both operands, so a null operand throws TypeError there and - * here, and two operands without a length of their own compare equal. */ + * `assert.compareArray`), and it has no shape checks of its own. It makes the + * upstream reads in the upstream order: `b.length`, then `a.length`, then + * `a.length` again before every element pair, `b[i]` before `a[i]`. A null + * operand throws TypeError there and here, and two operands without a length + * of their own compare equal. The loop bound converts the length the way the + * upstream `i < a.length` does, so a length of "2" compares two elements. */ export function compareArray(actual: unknown, expected: unknown): boolean { - const length = lengthOf(actual); - if (lengthOf(expected) !== length) return false; - for (let i = 0; typeof length === "number" && i < length; i++) { + if (lengthOf(expected) !== lengthOf(actual)) return false; + for (let i = 0; i < Number(lengthOf(actual)); i++) { if (!same(elementAt(expected, i), elementAt(actual, i))) return false; } return true; From 4d939e55c7f56616a6e153a2aa5113a84dff8d1d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 06:45:11 -0700 Subject: [PATCH 132/193] Sort directory entry names by their raw bytes before decoding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libuv's uv_fs_scandir sorts the raw directory-entry bytes with strcmp and decodes the names only after sorting. Sorting the decoded text instead reorders names that are not valid UTF-8: the malformed bytes 0x80 and 0xfe decode to U+FFFD, which sorts after "é", and distinct names that decode to the same text fall into an arbitrary order. readdir and readdirSync listed those names in an order Node never produces. scr_fs_scandir now collects the raw names, sorts them with strcmp, and then decodes each one into the ScrScandir. The malformed UTF-8 harness test also prints the unsorted readdir order and carries names whose byte order and decoded order differ. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_lib.c | 73 ++++++++++++----------- tests/harness/intl-malformed-utf8.test.ts | 15 ++++- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index a46868b20..58d91dda8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -6487,30 +6487,39 @@ struct ScrScandir { #ifndef _WIN32 /* libuv's uv_fs_scandir sorts entries with strcmp on POSIX hosts, so - * readdir, readdirSync and fs/promises readdir list names in byte order. */ + * readdir, readdirSync and fs/promises readdir list names in byte order. + * The sort runs on the raw entry bytes before decoding: a name that is not + * valid UTF-8 decodes to U+FFFD text that no longer orders like its bytes, + * and two malformed names can decode to the same text, so decoding first + * would list them in an order Node never produces. */ typedef struct { - ScrStr *name; + char *raw; unsigned char kind; } ScrScandirEntry; static int scr_fs_scandir_cmp(const void *a, const void *b) { - const ScrStr *x = ((const ScrScandirEntry *)a)->name, *y = ((const ScrScandirEntry *)b)->name; - size_t n = x->len < y->len ? x->len : y->len; - int c = memcmp(x->data, y->data, n); - return c ? c : (x->len > y->len) - (x->len < y->len); + return strcmp(((const ScrScandirEntry *)a)->raw, ((const ScrScandirEntry *)b)->raw); } -static void scr_fs_scandir_sort(ScrScandir *s) { - if (s->len < 2) return; - ScrScandirEntry *rows = malloc(s->len * sizeof *rows); - if (!rows) scr_trap("scriptc: out of memory\n"); - for (size_t i = 0; i < s->len; i++) rows[i] = (ScrScandirEntry){s->names[i], s->kinds[i]}; - qsort(rows, s->len, sizeof *rows, scr_fs_scandir_cmp); - for (size_t i = 0; i < s->len; i++) { - s->names[i] = rows[i].name; +/* Sorts the collected rows by their raw bytes, then decodes each name into a + * fresh ScrScandir and frees the rows. */ +static ScrScandir *scr_fs_scandir_from_rows(ScrScandirEntry *rows, size_t len) { + ScrScandir *s = malloc(sizeof *s); + if (!s) scr_trap("scriptc: out of memory\n"); + s->len = len; + s->cap = len; + s->names = len ? malloc(len * sizeof *s->names) : NULL; + s->kinds = len ? malloc(len) : NULL; + if (len && (!s->names || !s->kinds)) scr_trap("scriptc: out of memory\n"); + qsort(rows, len, sizeof *rows, scr_fs_scandir_cmp); + for (size_t i = 0; i < len; i++) { + /* Decoded as UTF-8 with U+FFFD for malformed bytes, as Node does. */ + s->names[i] = scr_str_from_utf8_lossy((const uint8_t *)rows[i].raw, strlen(rows[i].raw)); s->kinds[i] = rows[i].kind; + free(rows[i].raw); } free(rows); + return s; } static unsigned char scr_dirent_kind_of_mode(mode_t m) { @@ -6599,15 +6608,9 @@ ScrScandir *scr_fs_scandir(ScrStr *path) { scr_fs_throw(errno, "scandir", path); /* Node reports scandir */ return NULL; } - ScrScandir *s = malloc(sizeof *s); - if (!s) { - scr_trap("scriptc: out of memory\n"); - } - s->len = 0; - s->cap = 8; - s->names = malloc(s->cap * sizeof *s->names); - s->kinds = malloc(s->cap); - if (!s->names || !s->kinds) { + size_t len = 0, cap = 8; + ScrScandirEntry *rows = malloc(cap * sizeof *rows); + if (!rows) { scr_trap("scriptc: out of memory\n"); } const struct dirent *ent; @@ -6644,22 +6647,24 @@ ScrScandir *scr_fs_scandir(ScrStr *path) { : 0; } #endif - if (s->len == s->cap) { - s->cap *= 2; - s->names = realloc(s->names, s->cap * sizeof *s->names); - s->kinds = realloc(s->kinds, s->cap); - if (!s->names || !s->kinds) { + if (len == cap) { + cap *= 2; + rows = realloc(rows, cap * sizeof *rows); + if (!rows) { scr_trap("scriptc: out of memory\n"); } } - /* Decoded as UTF-8 with U+FFFD for malformed bytes, as Node does. */ - s->names[s->len] = scr_str_from_utf8_lossy((const uint8_t *)ent->d_name, strlen(ent->d_name)); - s->kinds[s->len] = kind; - s->len++; + size_t raw_len = strlen(ent->d_name); + rows[len].raw = malloc(raw_len + 1); + if (!rows[len].raw) { + scr_trap("scriptc: out of memory\n"); + } + memcpy(rows[len].raw, ent->d_name, raw_len + 1); + rows[len].kind = kind; + len++; } closedir(d); - scr_fs_scandir_sort(s); - return s; + return scr_fs_scandir_from_rows(rows, len); #endif } diff --git a/tests/harness/intl-malformed-utf8.test.ts b/tests/harness/intl-malformed-utf8.test.ts index 2b6abaddd..2293a60ca 100644 --- a/tests/harness/intl-malformed-utf8.test.ts +++ b/tests/harness/intl-malformed-utf8.test.ts @@ -2,7 +2,8 @@ * command-line arguments) decode as UTF-8 with U+FFFD for each malformed * subsequence, as Node decodes them, and a word segmenter over them matches * Node: overlong forms, truncated sequences, stray continuation bytes and - * UTF-8-encoded surrogates included. */ + * UTF-8-encoded surrogates included. readdir also lists those names in Node's + * raw byte order, which decoding can reorder. */ import { execFile } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -20,8 +21,9 @@ function show(label: string, text: string): void { const parts = [...words.segment(text)].map((part) => JSON.stringify(part.segment) + "@" + part.index); console.log(label, JSON.stringify(text), text.length, parts.join(" ")); } -const names = readdirSync(process.argv[2]!).sort(); -for (const name of names) show("name", name); +const names = readdirSync(process.argv[2]!); +for (const name of names) show("order", name); +for (const name of names.sort()) show("name", name); show("arg", process.argv[3]!); `; @@ -32,6 +34,13 @@ const NAMES = [ [0x73, 0x75, 0x72, 0xed, 0xa0, 0x80, 0x78], [0x6f, 0x6b, 0x2d, 0xc3, 0xa9, 0x2e, 0x74, 0x78, 0x74], [0x63, 0x6f, 0x6e, 0x74, 0x80, 0xbf, 0x20, 0x77, 0x6f, 0x72, 0x64], + /* Names whose raw bytes and decoded text order differently: the malformed + * lead byte 0x80 sorts before "é" as bytes but decodes to U+FFFD, which + * sorts after it, and 0xfe decodes to the same text as 0x80. Node lists + * them in raw byte order. */ + [0x80, 0x61], + [0xc3, 0xa9], + [0xfe, 0x61], ]; test.skipIf(process.platform !== "linux" || process.arch !== "x64")( From ac978fc686a517dd6532b6bb79056a553017b889 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:48:35 -0500 Subject: [PATCH 133/193] Don't offset a null pointer when an array has no dense storage scr_arr_alloc_start subtracted the prefix from the dense block even when the array had no storage, which computes NULL - 0. That is undefined in C, and the macOS lane's UndefinedBehaviorSanitizer reports it ("applying zero offset to null pointer" at scr_array.c:60) in scr_json.test.ts's weak-map program. Without a prefix, the function now returns the block as it is. --- packages/runtime/src/scr_array.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 7731c98ea..d90a2ec6f 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -55,9 +55,12 @@ static size_t scr_arr_prefix(const ScrArr *a) { return (a->spare & SCR_ARR_HAS_EXT) ? SCR_ARR_EXT_PREFIX : 0; } -/* The start of the block the dense storage was allocated as. */ +/* The start of the block the dense storage was allocated as. Without a + * prefix it is the block itself, which is NULL while the array has no + * storage (offsetting a null pointer, even by 0, is undefined). */ static void *scr_arr_alloc_start(const ScrArr *a) { - return (char *)scr_arr_block(a) - scr_arr_prefix(a); + size_t prefix = scr_arr_prefix(a); + return prefix ? (char *)scr_arr_block(a) - prefix : (void *)scr_arr_block(a); } /* Bytes per dense slot: an 8-byte value or a boolean byte, and its state From eccb061a121e56527a6663e04cdad9df2c406b73 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:51:27 -0500 Subject: [PATCH 134/193] Find kept record sources through index signatures and record fields The capture into an index-signature record consults a record's kept source view only when some checked conversion in the program can keep one. That scan accepted a closed record target and looked through union arms, promises and arrays, but stopped at an index-signature record or a record whose fields hold closed records. A conversion into `Record` keeps each value's undeclared keys, so a program whose only such conversion had that shape dropped them in the capture. The scan now looks through record fields and index-signature values too, memoizing records by shape as it does unions. --- .../src/frontend/lowering/record-sources.ts | 52 ++++++++++++------- .../record-source-keys-index-values.ts.json | 6 +++ .../corpus/record-source-keys-index-values.ts | 19 +++++++ .../record-source-keys-index-values.ts.json | 1 + 4 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json create mode 100644 tests/corpus/record-source-keys-index-values.ts create mode 100644 tests/harness/corpus-costs/record-source-keys-index-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/record-sources.ts b/packages/compiler/src/frontend/lowering/record-sources.ts index 1e7ce254d..692b4e1f8 100644 --- a/packages/compiler/src/frontend/lowering/record-sources.ts +++ b/packages/compiler/src/frontend/lowering/record-sources.ts @@ -40,9 +40,9 @@ export function recordSourceTest(value: IrExpr, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "record.sourceHas", args: [value], type: BOOL, loc }; } -/** A union revisited while it is being visited answers as if its cycle arm - * were empty (`keeps: false`), so the caller's answer is incomplete until the - * outer union settles. `cycled` marks that incompleteness: such an answer is +/** A union or record revisited while it is being visited answers as if its + * cycle member were empty (`keeps: false`), so the caller's answer is + * incomplete until the outer one settles. `cycled` marks that incompleteness: such an answer is * never memoized, because another entry point into the same cycle could reach * the missing arm. */ interface KeepsSourceAnswer { @@ -50,8 +50,8 @@ interface KeepsSourceAnswer { cycled: boolean; } -/** Shared work list of the whole-program scan: settled union answers, and the - * unions on the current path. */ +/** Shared work list of the whole-program scan: settled union and record + * answers, and the unions and records on the current path, by id. */ export interface KeepsSourceScan { memo: Map; visiting: Set; @@ -59,33 +59,47 @@ export interface KeepsSourceScan { /** Whether a checked conversion into `target` can leave a record holding * undeclared source keys. The conversion reads one record at a time, so the - * records nested in a union arm, a promise, or an array count too. */ + * records nested in a union arm, a promise, an array, a record field or an + * index signature's values count too (`Record` keeps + * the sources of its closed values). Unions and records are memoized by id; + * one revisited on the current path answers `cycled`. */ function conversionKeepsSource( lowerer: Lowerer, target: IrType, scan: KeepsSourceScan, ): KeepsSourceAnswer { - if (target.kind === "record") - return { keeps: recordMayKeepSource(lowerer, target), cycled: false }; if (target.kind === "promise") return conversionKeepsSource(lowerer, target.inner, scan); if (target.kind === "array") return conversionKeepsSource(lowerer, target.elem, scan); - if (target.kind !== "union") return { keeps: false, cycled: false }; - const known = scan.memo.get(target.unionId); + if (target.kind === "record" && recordMayKeepSource(lowerer, target)) + return { keeps: true, cycled: false }; + let id: string; + let members: IrType[]; + if (target.kind === "union") { + id = target.unionId; + members = lowerer.unions.get(target.unionId)?.arms ?? []; + } else if (target.kind === "record") { + id = target.shapeId; + const shape = lowerer.shapes.get(target.shapeId); + members = [ + ...(shape?.fields.map((field) => field.type) ?? []), + ...(shape?.indexValue !== undefined ? [shape.indexValue] : []), + ]; + } else return { keeps: false, cycled: false }; + const known = scan.memo.get(id); if (known !== undefined) return { keeps: known, cycled: false }; - if (scan.visiting.has(target.unionId)) return { keeps: false, cycled: true }; - const union = lowerer.unions.get(target.unionId); - if (union === undefined) return { keeps: false, cycled: false }; - scan.visiting.add(target.unionId); + if (scan.visiting.has(id)) return { keeps: false, cycled: true }; + scan.visiting.add(id); let keeps = false; let cycled = false; - for (const arm of union.arms) { - const answer = conversionKeepsSource(lowerer, arm, scan); + for (const member of members) { + const answer = conversionKeepsSource(lowerer, member, scan); keeps = keeps || answer.keeps; cycled = cycled || answer.cycled; + if (keeps) break; } - scan.visiting.delete(target.unionId); - if (!cycled) scan.memo.set(target.unionId, keeps); - return { keeps, cycled }; + scan.visiting.delete(id); + if (!cycled || keeps) scan.memo.set(id, keeps); + return { keeps, cycled: cycled && !keeps }; } /** Whether `fn` holds a validated conversion that can note a record source: diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..72d9a1314 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-source-keys-index-values.ts b/tests/corpus/record-source-keys-index-values.ts new file mode 100644 index 000000000..92d6077e2 --- /dev/null +++ b/tests/corpus/record-source-keys-index-values.ts @@ -0,0 +1,19 @@ +// A checked conversion into an index-signature record whose values are +// closed records keeps each value's undeclared source keys, so converting +// such a value into an index-signature record later copies them. This +// program has no checked conversion into a closed record itself, so the +// capture learns that kept sources are possible from the index signature's +// values. Node prints "extra,x". +type H = Record; +interface T { + transformHeaders?: (h: H) => H | Promise; +} +const parsed: Record = JSON.parse('{"p":{"x":"1","extra":"e"}}'); +async function viaArrow(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} +viaArrow({ transformHeaders: async (h) => parsed["p"]! }).then((r) => + console.log(Object.keys(r).sort().join(",")), +); diff --git a/tests/harness/corpus-costs/record-source-keys-index-values.ts.json b/tests/harness/corpus-costs/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..143bf22a5 --- /dev/null +++ b/tests/harness/corpus-costs/record-source-keys-index-values.ts.json @@ -0,0 +1 @@ +{ "plain": 1959, "san": 3000 } From 851d3362c2d9b40264d12c928b49650af3648302 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:55:52 -0500 Subject: [PATCH 135/193] Give the Intel macOS release build 90 minutes --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae28f585f..bdbe0edde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -231,8 +231,8 @@ jobs: llvm_dist: x64 use_zig: true runs-on: ${{ matrix.runner }} - # The Intel macOS build took 24 minutes for 0.3.2 and passed 30 for 0.3.3. - timeout-minutes: 45 + # The Intel macOS build took 24 minutes for 0.3.2, 40 for 0.4.5, and passed 45 for 0.4.6. + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 From 3ee8c41cbd09581764bd65e54bf6987fe9c89fd2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 09:12:40 -0500 Subject: [PATCH 136/193] Append long flatMap tuple arms in index order, and read omitted generic members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flatMap callback's tuple arm appended its elements in the stored field order, which sorts the names as strings, so a tuple of eleven or more elements came out as 0, 1, 10, 2, …. The elements now append in index order. A generic receiver's member that the instantiation does not store reads through the checked keyed read, but only for an access that is not a write. The write test took any binary expression's left operand and any unary operand for a write, so `options.key ?? fallback`, `options.key === undefined` and `!options.key` still failed with SC1090. Only an assignment's target, a delete, and `++`/`--` count as writes now. --- .../lowering/containers/array-callbacks.ts | 7 ++++++- .../lowering/expressions/property-access.ts | 18 +++++++++++++++--- .../corpus/flatmap-long-tuple-arms.ts.json | 6 ++++++ .../generic-omitted-member-reads.ts.json | 6 ++++++ tests/corpus/flatmap-long-tuple-arms.ts | 11 +++++++++++ tests/corpus/generic-omitted-member-reads.ts | 16 ++++++++++++++++ .../flatmap-long-tuple-arms.ts.json | 1 + .../generic-omitted-member-reads.ts.json | 1 + 8 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json create mode 100644 tests/corpus/flatmap-long-tuple-arms.ts create mode 100644 tests/corpus/generic-omitted-member-reads.ts create mode 100644 tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json create mode 100644 tests/harness/corpus-costs/generic-omitted-member-reads.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 97fc18928..d4183d5e5 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -1936,7 +1936,12 @@ function flatMapUnionHelper( if (plan.arms[tag] === "empty") return []; if (plan.arms[tag] === "tuple") { const tuple = arm as IrType & { kind: "record" }; - return (lowerer.shapes.get(tuple.shapeId)?.fields ?? []).map((field) => { + // The fields are stored by name ("0", "1", "10", "2", …): the + // elements append in index order. + const elements = [...(lowerer.shapes.get(tuple.shapeId)?.fields ?? [])].sort( + (a, b) => Number(a.name) - Number(b.name), + ); + return elements.map((field) => { const read: IrExpr = { kind: "recordGet", obj: narrowed, diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3c0a8de3c..9cf5556c4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4334,13 +4334,25 @@ function typeParameterReceiver(t: ts.Type): boolean { } /** Whether `access` is only READ (not the target of an assignment, a - * delete, or an increment). */ + * delete, or an increment). The left operand of `??`, `===` or `&&`, and + * the operand of `!`, `-` or `typeof`, are reads. */ function readOnlyAccess(access: ts.PropertyAccessExpression): boolean { const parent = access.parent; if (parent === undefined) return true; - if (ts.isBinaryExpression(parent) && parent.left === access) return false; + if ( + ts.isBinaryExpression(parent) && + parent.left === access && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + return false; if (ts.isDeleteExpression(parent)) return false; - return !ts.isPrefixUnaryExpression(parent) && !ts.isPostfixUnaryExpression(parent); + if (ts.isPostfixUnaryExpression(parent)) return false; + return ( + !ts.isPrefixUnaryExpression(parent) || + (parent.operator !== ts.SyntaxKind.PlusPlusToken && + parent.operator !== ts.SyntaxKind.MinusMinusToken) + ); } export function fieldTarget( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json new file mode 100644 index 000000000..9740b7331 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/flatmap-long-tuple-arms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json new file mode 100644 index 000000000..b6135d575 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-omitted-member-reads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/flatmap-long-tuple-arms.ts b/tests/corpus/flatmap-long-tuple-arms.ts new file mode 100644 index 000000000..8c6898c94 --- /dev/null +++ b/tests/corpus/flatmap-long-tuple-arms.ts @@ -0,0 +1,11 @@ +// A flatMap callback whose tuple arm has more than ten elements appends +// them in index order. +function spread(values: number[]): number[] { + return values.flatMap((n) => { + const base = n * 100; + return n + ? ([base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11] as const) + : []; + }); +} +console.log(spread([1, 0, 2]).join(",")); diff --git a/tests/corpus/generic-omitted-member-reads.ts b/tests/corpus/generic-omitted-member-reads.ts new file mode 100644 index 000000000..7b31609c0 --- /dev/null +++ b/tests/corpus/generic-omitted-member-reads.ts @@ -0,0 +1,16 @@ +// A generic options bag read through its constraint at a call site whose +// literal omits the member: `??`, `===`, `!` and `typeof` read the +// property, which Node answers as undefined. +interface Options { + key?: string; + label?: string; +} +function describe(options: O): string { + const a = options.key ?? "fallback"; + const b = options.key === undefined; + const c = !options.key; + const d = typeof options.key; + return `${a} ${b} ${c} ${d}`; +} +console.log(describe({ label: "x" })); +console.log(describe({ key: "k", label: "y" })); diff --git a/tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json b/tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json new file mode 100644 index 000000000..08d57661f --- /dev/null +++ b/tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 1455, "san": 3000 } diff --git a/tests/harness/corpus-costs/generic-omitted-member-reads.ts.json b/tests/harness/corpus-costs/generic-omitted-member-reads.ts.json new file mode 100644 index 000000000..15ea2ceb0 --- /dev/null +++ b/tests/harness/corpus-costs/generic-omitted-member-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 1603, "san": 3000 } From 1ace0b4e88ad37991192b59da7086735fb517f78 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 09:14:55 -0500 Subject: [PATCH 137/193] Expect the map and record-width results the PR's lowering now gives --- packages/compiler/test/map-closure-elements.test.ts | 10 +++++----- tests/harness/__snapshots__/record-width.ts.txt | 9 --------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/compiler/test/map-closure-elements.test.ts b/packages/compiler/test/map-closure-elements.test.ts index 2043b2bce..f39281579 100644 --- a/packages/compiler/test/map-closure-elements.test.ts +++ b/packages/compiler/test/map-closure-elements.test.ts @@ -58,7 +58,7 @@ export {}; expect(validateModule(result.module)).toEqual([]); }); -test("a void-returning map callback still has no element to store", async () => { +test("a void-returning map callback stores undefined elements", async () => { const result = await lower(` const items = [1, 2]; const out = items.map((n) => { @@ -67,8 +67,8 @@ const out = items.map((n) => { console.log(out.length); export {}; `); - // `void[]` stays unrepresentable: a void value is erased before it could - // be written. Only the CLOSURE element became an ordinary element. - expect(result.ok).toBe(false); - if (!result.ok) expect(result.codes).toEqual(["SC2009"]); + // A discarding mapper's answer is undefined, which the result array + // stores as its element (as Node's map does). + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); }); diff --git a/tests/harness/__snapshots__/record-width.ts.txt b/tests/harness/__snapshots__/record-width.ts.txt index 37be4df54..4c8744a3c 100644 --- a/tests/harness/__snapshots__/record-width.ts.txt +++ b/tests/harness/__snapshots__/record-width.ts.txt @@ -7,15 +7,6 @@ record-width.ts:12:25 - error SC2002: record shapes must match exactly or width- hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead -record-width.ts:26:20 - error SC2002: record shapes must match exactly or width-coerce: expected '{ m: ({ a: number; b: number }) => void }', got '{ extra: number; m: ({ a: number }) => void }' — field 'm': '({ a: number }) => void' does not lift into '({ a: number; b: number }) => void' - - 25 | }; - 26 | const dstM: DstM = srcM; - | ^~~~ - 27 | console.log(dstM); - - hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead - record-width.ts:27:13 - error SC1090: console.log of '{ m: ({ a: number; b: number }) => void }' values (function values have no inspect lowering (no runtime name exists — Node prints '[Function: name]'; pass the function's declared identifier directly to util.inspect for the baked form)) is not supported yet 26 | const dstM: DstM = srcM; From 78400c316fff9a4e4172560b774f0f3b068b20d2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 09:19:10 -0500 Subject: [PATCH 138/193] Give a default export the name default in reuse's declaration tables A default export without a name of its own (`export default function (...)`, `export default class`, an `export default` expression) declared no name, so a change to it left the units of its default imports applied. Each default export now declares `default` beside its own names, and a change to it reaches the names that import it. --- .../src/frontend/lowering/unit-reuse.ts | 22 ++++++++--- packages/compiler/test/unit-reuse.test.ts | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/unit-reuse.ts b/packages/compiler/src/frontend/lowering/unit-reuse.ts index 0a5289c77..7d89b996d 100644 --- a/packages/compiler/src/frontend/lowering/unit-reuse.ts +++ b/packages/compiler/src/frontend/lowering/unit-reuse.ts @@ -428,6 +428,22 @@ function surfaceText(sf: ts.SourceFile, node: ts.Node, js: boolean): string { * base of an expando `f.prop = value`; in JavaScript, where such statements * also define exports, every name and string it spells. */ function statementNames(statement: ts.Statement, js: boolean, hooks: UnitReuseHooks): string[] { + const names = declaredNames(statement, js, hooks); + // A default export also declares the module's `default`, the name a + // default import holds (Alias), also when it has no name of its own. + return isDefaultExport(statement) && !names.includes("default") ? [...names, "default"] : names; +} + +/** Whether `statement` is its module's default export: `export default` + * or `export =`. */ +function isDefaultExport(statement: ts.Statement): boolean { + if (ts.isExportAssignment(statement)) return true; + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true; +} + +/** The names `statement` declares (statementNames), apart from `default`. */ +function declaredNames(statement: ts.Statement, js: boolean, hooks: UnitReuseHooks): string[] { if (ts.isVariableStatement(statement)) { const names: string[] = []; for (const declaration of statement.declarationList.declarations.nodes) { @@ -575,11 +591,7 @@ function aliasesOf(statement: ts.Statement, names: readonly string[], out: Alias if (clause !== undefined && ts.isNamedExports(clause)) specifiers(clause.elements.nodes); return; } - const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; - if ( - ts.isExportAssignment(statement) || - modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true - ) { + if (isDefaultExport(statement)) { for (const name of names) add(name, "default"); return; } diff --git a/packages/compiler/test/unit-reuse.test.ts b/packages/compiler/test/unit-reuse.test.ts index da79ced88..b455e33bc 100644 --- a/packages/compiler/test/unit-reuse.test.ts +++ b/packages/compiler/test/unit-reuse.test.ts @@ -378,3 +378,41 @@ test("an import of a renamed export or of a default export lowers again when its await workspace.dispose(); } }, 300_000); + +test("an import of an anonymous default export lowers again when its signature changes", async () => { + const workspace = await Workspace.create({ + "values.ts": [ + "export default function (value: number): number {", + " return value * 2;", + "}", + "export function other(value: number): number {", + " return value + 1;", + "}", + "", + ].join("\n"), + "main.ts": [ + 'import grow, { other } from "./values.js";', + "function grown(value: number): number {", + " return grow(value);", + "}", + "function next(value: number): number {", + " return other(value);", + "}", + "console.log(grown(4), next(1));", + "", + ].join("\n"), + }); + try { + await workspace.build(); + await workspace.edit( + "values.ts", + "export default function (value: number): number {", + "export default function (value: number, by = 1): number {", + ); + const applied = await workspace.build(); + expect(has(applied, "grown")).toBe(false); + expect(has(applied, "next")).toBe(true); + } finally { + await workspace.dispose(); + } +}, 300_000); From da6665a98a333ced1938955bcca8e293dbcafdaa Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 09:20:26 -0500 Subject: [PATCH 139/193] Keep partition placement stable when an edit adds a small helper Roots under one 4 KiB unit, mostly runtime helpers, no longer count in the partition loads that balancing compares. They are placed after the larger roots, each in the first open partition from its name's partition whose load from the larger roots is within the cap, so where a helper goes depends only on the larger roots. A helper that an edit adds or removes used to change the total and the cap and move several other roots, and every partition they left or joined was compiled again. A root's size also leaves out the digits of metadata references, which an edit elsewhere in the module can renumber, so an unchanged function no longer crosses a unit boundary because its references were renumbered. --- native/llvm-codegen/src/partition.cpp | 93 +++++++++++++------ .../compiler/test/native-partitions.test.ts | 75 +++++++++++++++ 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp index d56839426..c196fed96 100644 --- a/native/llvm-codegen/src/partition.cpp +++ b/native/llvm-codegen/src/partition.cpp @@ -176,71 +176,104 @@ void balanceRoots(std::vector &Symbols, ArrayRef Roots, ArrayRef Live, unsigned Count) { if (Count >= MinGroupedPartitions) return; + // A root's size leaves out the digits of metadata references, which an + // edit elsewhere in the module can renumber. + auto TextBytes = [](StringRef Text) { + uint64_t Bytes = 0; + for (size_t I = 0; I < Text.size(); ++I) { + ++Bytes; + if (Text[I] == '!') + while (I + 1 < Text.size() && isDigit(Text[I + 1])) + ++I; + } + return Bytes; + }; DenseMap RootBytes; - uint64_t Total = 0; for (unsigned I = 0; I < Symbols.size(); ++I) if (Symbols[I].Kind == SymbolKind::Function && Live[I]) - RootBytes[Roots[I]] += Symbols[I].Header.size() + Symbols[I].Body.size(); - // Each root counts in whole 4 KiB units, so that an edit that grows a root - // by less than that leaves every comparison, and every other root's - // partition, unchanged. - constexpr uint64_t Unit = 4096; - for (auto &Entry : RootBytes) { - Entry.second = (Entry.second + Unit - 1) / Unit * Unit; - Total += Entry.second; - } + RootBytes[Roots[I]] += + TextBytes(Symbols[I].Header) + TextBytes(Symbols[I].Body); std::vector Order; for (auto &Entry : RootBytes) Order.push_back(Entry.first); llvm::sort(Order, [&](unsigned A, unsigned B) { return Symbols[A].Name < Symbols[B].Name; }); + DenseSet EntryRoots; + for (unsigned I = 0; I < Symbols.size(); ++I) + if (Symbols[I].Entry && Live[I]) + EntryRoots.insert(Roots[I]); + // A root of at least one unit counts in whole units, so that an edit that + // grows it by less than that leaves every comparison, and every other + // root's partition, unchanged. + constexpr uint64_t Unit = 4096; + uint64_t Total = 0; + for (unsigned Root : Order) + if (RootBytes[Root] >= Unit) { + RootBytes[Root] = (RootBytes[Root] + Unit - 1) / Unit * Unit; + Total += RootBytes[Root]; + } uint64_t Cap = Total * 5 / 4 / Count; std::vector Load(Count, 0); // Partitions that take no further root. std::vector Closed(Count, 0); DenseMap Placed; + auto FirstOpen = [&](unsigned Home) { + for (unsigned K = 0; K < Count; ++K) + if (!Closed[(Home + K) % Count]) + return (Home + K) % Count; + return Home; + }; // A root without room anywhere stays in the first open partition from its // own. auto Place = [&](unsigned Root) { uint64_t Bytes = RootBytes[Root]; - unsigned Home = stableHash(Symbols[Root].Name) % Count, Chosen = Home; - bool Found = false; - for (unsigned K = 0; K < Count && !Found; ++K) { + unsigned Home = stableHash(Symbols[Root].Name) % Count; + unsigned Chosen = FirstOpen(Home); + for (unsigned K = 0; K < Count; ++K) { unsigned P = (Home + K) % Count; if (!Closed[P] && Load[P] + Bytes <= Cap) { Chosen = P; - Found = true; - } - } - for (unsigned K = 0; K < Count && !Found; ++K) { - unsigned P = (Home + K) % Count; - if (!Closed[P]) { - Chosen = P; - Found = true; + break; } } Load[Chosen] += Bytes; Placed[Root] = Chosen; }; - DenseSet EntryRoots; - for (unsigned I = 0; I < Symbols.size(); ++I) - if (Symbols[I].Entry && Live[I]) - EntryRoots.insert(Roots[I]); - // Entry roots first, each closing its partition, then roots of more than - // half the cap, in name order, so that small roots fill around them; the - // others follow in name order. + // Entry roots first, each closing its partition; then roots of more than + // half the cap, in name order, so that the others fill around them; then + // the other roots of at least one unit, in name order. for (unsigned Root : Order) if (EntryRoots.contains(Root)) { Place(Root); Closed[Placed[Root]] = 1; } for (unsigned Root : Order) - if (!EntryRoots.contains(Root) && RootBytes[Root] * 2 > Cap) + if (!EntryRoots.contains(Root) && RootBytes[Root] >= Unit && + RootBytes[Root] * 2 > Cap) Place(Root); for (unsigned Root : Order) - if (!EntryRoots.contains(Root) && RootBytes[Root] * 2 <= Cap) + if (!EntryRoots.contains(Root) && RootBytes[Root] >= Unit && + RootBytes[Root] * 2 <= Cap) Place(Root); + // Roots under one unit (runtime helpers, mostly) go to the first open + // partition from their names' whose load from the larger roots leaves + // room, and count in no load: where a helper goes depends only on the + // larger roots, so a helper that an edit adds or removes moves no other + // root. + for (unsigned Root : Order) + if (!EntryRoots.contains(Root) && RootBytes[Root] < Unit) { + unsigned Home = stableHash(Symbols[Root].Name) % Count; + unsigned Chosen = FirstOpen(Home); + for (unsigned K = 0; K < Count; ++K) { + unsigned P = (Home + K) % Count; + if (!Closed[P] && Load[P] < Cap) { + Chosen = P; + break; + } + } + Placed[Root] = Chosen; + } for (unsigned I = 0; I < Symbols.size(); ++I) if (Symbols[I].Kind == SymbolKind::Function && Live[I]) Symbols[I].Partition = Placed[Roots[I]]; diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index ea906eea7..215f6a718 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -865,6 +865,81 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { } }); + test("a small helper that an edit adds moves no other function", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-helper-roots-")); + dirs.push(dir); + // Eight exported functions of these instruction counts and three small + // exported helpers, each a root of its own. The edit adds a fourth + // helper, which big1 calls. Counted in the loads, the added helper would + // raise the cap and move other functions. + const sizes = [426, 201, 333, 376, 152, 339, 327, 218]; + const helper = (name: string) => [ + `define i64 @${name}(i64 %value) noinline {`, + " %r = add i64 %value, 1", + " ret i64 %r", + "}", + ]; + const source = (edited: boolean) => { + const lines: string[] = []; + sizes.forEach((size, index) => { + lines.push(`define i64 @big${index}(i64 %value) noinline {`); + for (let step = 0; step < size; step++) + lines.push(` %v${step} = add i64 ${step === 0 ? "%value" : `%v${step - 1}`}, 1`); + if (edited && index === 1) + lines.push(` %h = call i64 @added(i64 %v${size - 1})`, " ret i64 %h"); + else lines.push(` ret i64 %v${size - 1}`); + lines.push("}"); + }); + for (let index = 0; index < 3; index++) lines.push(...helper(`helper${index}`)); + if (edited) lines.push(...helper("added")); + lines.push("define i32 @main() {"); + sizes.forEach((_, index) => + lines.push( + ` %r${index} = call i64 @big${index}(i64 ${index})`, + ` %t${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %r${index}`, + ), + ); + for (let index = 0; index < 3; index++) { + const operand = index === 0 ? `%t${sizes.length - 1}` : `%h${index - 1}`; + lines.push(` %h${index} = call i64 @helper${index}(i64 ${operand})`); + } + lines.push( + " %low = urem i64 %h2, 251", + " %code = trunc i64 %low to i32", + " ret i32 %code", + "}", + ); + return lines.join("\n") + "\n"; + }; + const expected = (edited: boolean) => { + let total = 3 + (edited ? 1 : 0); + sizes.forEach((size, index) => (total += index + size)); + return total % 251; + }; + const placed = async (objects: string[]) => { + const where = new Map(); + for (const [index, object] of objects.entries()) { + const { stdout } = await execFileAsync("nm", ["--defined-only", object]); + for (const line of stdout.split("\n")) { + const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(big\d+|helper\d+|main)$/.exec(line.trim()); + if (defined !== null) where.set(defined[1]!, index); + } + } + return where; + }; + const cache = join(dir, "cache"); + const options = { outputs: 4, optLevel: "0", cache } as const; + const objects = await emit(dir, source(false), { ...options, name: "before" }); + expect(await exitCode(dir, objects, "before")).toBe(expected(false)); + const before = await cacheEntries(cache, ".o"); + const edited = await emit(dir, source(true), { ...options, name: "after" }); + expect(await exitCode(dir, edited, "after")).toBe(expected(true)); + expect(await placed(edited)).toEqual(await placed(objects)); + // Only big1's partition and the new helper's change. + const changed = [...(await cacheEntries(cache, ".o"))].filter((entry) => !before.has(entry)); + expect(changed.length).toBeLessThanOrEqual(2); + }); + test("a table written one element per line keeps the text partitioner and its reuse", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-table-")); dirs.push(dir); From 7aaf98b53c20e6400623bc4c78be98b3ee676b59 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 10:24:12 -0500 Subject: [PATCH 140/193] Check nullable destructuring sources through their unit arm The TypeError check for a null or undefined source now reads the unit arm the value holds as a dyn null or undefined, instead of converting the whole union to dyn. A union whose data arm has no dyn form, such as a record holding a generator, compiles again instead of failing with SC9001. --- .../src/frontend/lowering/lower-stmts.ts | 36 +++++++++++++++++-- .../destructure-nullable-union-source.js | 30 ++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 05a8cfa2d..502761002 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2581,14 +2581,46 @@ export function lowerDestructuringDecl( (arm.kind === "array" || arm.kind === "record") ) { const tag = def.arms.indexOf(arm); + const unionId = init.type.unionId; const utmp = lowerer.declareHiddenLocal("%destrU", init.type); out.push({ kind: "varDecl", localId: utmp.id, init, loc }); const uref: IrExpr = { kind: "varRef", localId: utmp.id, type: init.type, loc }; + // The failure branch holds one of the unit arms: the check reads it + // as the dyn null or undefined it is, so the data arm needs no dyn + // form (a record holding a generator has none). + const unitValue = (): IrExpr => { + const unitOf = (t: IrType): IrExpr => + lowerer.coerceToExpected( + { kind: "unitLit", unit: t.kind === "nullT" ? "null" : "undefined", type: t, loc }, + DYN, + ); + const units = def.arms.filter(isUnitType); + const last = units[units.length - 1]!; + return units.slice(0, -1).reduceRight( + (rest, t) => ({ + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag: def.arms.indexOf(t), + negated: false, + value: uref, + type: BOOL, + loc, + }, + then: unitOf(t), + else_: rest, + type: DYN, + loc, + }), + unitOf(last), + ); + }; const spelling = destrSpellingOf(src); const check: IrExpr | null = ts.isArrayBindingPattern(decl.name) ? { kind: "dynIterN", - value: lowerer.coerceToExpected(uref, DYN), + value: unitValue(), count: decl.name.elements.nodes.length, ...(dynSpell ? { notIterableMessage: dynSpell } : {}), type: DYN, @@ -2597,7 +2629,7 @@ export function lowerDestructuringDecl( : spelling !== null ? { kind: "dynDestrCheck", - value: lowerer.coerceToExpected(uref, DYN), + value: unitValue(), spelling, ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), type: DYN, diff --git a/tests/corpus/destructure-nullable-union-source.js b/tests/corpus/destructure-nullable-union-source.js index 84af858bf..b1d9de7b3 100644 --- a/tests/corpus/destructure-nullable-union-source.js +++ b/tests/corpus/destructure-nullable-union-source.js @@ -56,6 +56,36 @@ show("array-function-call", () => { console.log("never", first); }); +// A source that may be null or undefined, and data arms holding a +// generator, which has no untyped form. +function* count() { + yield 1; + yield 2; +} +function pick(kind) { + if (kind === 0) return null; + if (kind === 1) return undefined; + return { counter: count(), label: "picked" }; +} +function counters(present) { + return present ? [count(), count()] : null; +} +show("object-null-arm", () => { + const { counter } = pick(0); + console.log("never", counter); +}); +show("object-undefined-arm", () => { + const { label } = pick(1); + console.log("never", label); +}); +show("array-generator-elements", () => { + const [first] = counters(false); + console.log("never", first); +}); +const { counter, label } = pick(2); +const [left, right] = counters(true); +console.log(label, [...counter].join(","), [...left, ...right].join(",")); + // Sources that hold a value bind as before. const { index } = "abc".match(/b/); const [whole] = "abc".match(/c/); From 8ea6b21f56aa2305b3de197bfad8cb0b856b92d1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 10:28:08 -0500 Subject: [PATCH 141/193] Reject import() of specifiers nothing resolves with Node's ERR_MODULE_NOT_FOUND A literal import() whose specifier resolves to no module lowered to a deferred SC2012 refusal. It now rejects when the call runs, as Node does: a package nothing installed provides rejects with "Cannot find package", and a relative or absolute path to a file that does not exist rejects with "Cannot find module" and the file's URL as the error's url property. Builtins and URL specifiers keep their current lowering. error.nodeThrow takes an optional fourth argument for the url, which the runtime sets through scr_throw_node_coded_url. --- .../compiler/src/backend/llvm/lib-process.ts | 17 ++++++-- .../src/frontend/lowering/lower-modules.ts | 40 ++++++++++++++----- .../src/frontend/lowering/lower-stdlib.ts | 2 +- packages/compiler/src/frontend/npm.ts | 27 +++++++++++++ packages/compiler/src/frontend/program.ts | 3 ++ packages/compiler/src/ir/build.ts | 5 ++- .../compiler/src/ir/builtin-signatures.ts | 7 ++-- packages/compiler/src/ir/validate.ts | 10 ++++- .../corpus/dynamic-import-not-found.mjs.json | 6 +++ packages/runtime/src/scr_dyn.h | 4 ++ packages/runtime/src/scr_json.c | 14 +++++++ tests/corpus/dynamic-import-not-found.mjs | 26 ++++++++++++ .../dynamic-import-not-found.mjs.json | 1 + 13 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json create mode 100644 tests/corpus/dynamic-import-not-found.mjs create mode 100644 tests/harness/corpus-costs/dynamic-import-not-found.mjs.json diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 01efd3354..bd7fb2ca2 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -811,10 +811,19 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr const kind = host.emitExpr(e.args[0]!); const code = host.emitExpr(e.args[1]!); const msg = host.emitExpr(e.args[2]!); - host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`); - B.line( - `call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`, - ); + if (e.args.length > 3) { + // The fourth argument: the error's own `url` property. + const url = host.emitExpr(e.args[3]!); + host.declare(`declare void @scr_throw_node_coded_url(double, ptr, ptr, ptr)`); + B.line( + `call void @scr_throw_node_coded_url(double ${kind.name}, ptr ${code.name}, ptr ${msg.name}, ptr ${url.name})`, + ); + } else { + host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`); + B.line( + `call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`, + ); + } const ty = host.llType(e.type); if (ty === "void") { host.emitAlwaysPendingUnwind(); diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index f82728c6e..6045e8c98 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -24,7 +24,12 @@ import { sharedModuleRegistrations, } from "./lower-shared-modules.js"; import { dirname as dirnamePath, resolve as resolvePath } from "node:path"; -import { packageNameOfPath, probeNodeImportRefusal } from "../npm.js"; +import { + packageNameOfPath, + probeNodeFileImportRefusal, + probeNodeImportRefusal, + type NodeImportRefusal, +} from "../npm.js"; import { isNpmStaticPackage } from "../npm-static.js"; import { isJsSourceFileName } from "../tsc-codes.js"; import { isRelativeSpecifier } from "../workspace-registry.js"; @@ -753,23 +758,38 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { * runtime-computed name). */ /** The refusal a literal import() rejects with, or null when it loads: * Node's ERR_MODULE_NOT_FOUND for a package the project marks never loaded - * (package-shims.ts), or the first refusal in the static graph of a module - * only import() loads (LoadResult.dynamicImportRejections). */ + * (package-shims.ts), the first refusal in the static graph of a module + * only import() loads (LoadResult.dynamicImportRejections), or Node's + * resolution refusal for a specifier nothing resolves (a package nothing + * installed provides, a relative path to a file that does not exist). */ function dynamicImportRejectionOf( lowerer: Lowerer, sf: ts.SourceFile, spec: string, call: ts.CallExpression, ): StartupCrash | null { - if (isUnloadedPackage(spec)) { - const refusal = probeNodeImportRefusal(sf.fileName, spec); - if (refusal === null) return null; - return { message: refusal.message, className: "%Error", code: refusal.code, loc: locOf(call) }; - } - if (lowerer.dynamicImportRejections.size === 0) return null; + const rejection = (refusal: NodeImportRefusal | null): StartupCrash | null => + refusal === null + ? null + : { + message: refusal.message, + className: "%Error", + code: refusal.code, + ...(refusal.url === undefined ? {} : { url: refusal.url }), + loc: locOf(call), + }; + if (isUnloadedPackage(spec)) return rejection(probeNodeImportRefusal(sf.fileName, spec)); const root = resolveImport(lowerer.program, sf, spec) ?? npmStaticDepSf7(lowerer.program, sf, spec); - return root === null ? null : (lowerer.dynamicImportRejections.get(root) ?? null); + if (root !== null) return lowerer.dynamicImportRejections.get(root) ?? null; + // Builtins, and URL specifiers (file:, data:), are not package names. + if (canonicalBuiltinModule(spec) !== null || /^[a-z][a-z0-9+.-]*:/i.test(spec)) return null; + return rejection( + probeNodeFileImportRefusal(sf.fileName, spec) ?? + (spec.startsWith(".") || spec.startsWith("/") + ? null + : probeNodeImportRefusal(sf.fileName, spec)), + ); } function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 44f2b7370..0c0c7131f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2289,7 +2289,7 @@ function lowerRejectedImport( { kind: "exprStmt", expr: hop, loc }, { kind: "exprStmt", - expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc), + expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc, crash.url), loc, }, { kind: "return", value: dynUndefinedExpr(loc), loc }, diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index 6cb1d3b5e..fff437ac0 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -1,5 +1,6 @@ /** Node package resolution shared by preflight and native module introspection. */ import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js"; import { trackedDirectoryExists, @@ -142,6 +143,32 @@ export function probeNodeImportRefusal( } } +/** Node's import() refusal for a relative or absolute specifier whose + * target does not exist: ERR_MODULE_NOT_FOUND naming the resolved path, with + * its file URL. An existing file or directory answers null, as do other + * specifier forms. ESM resolution adds no extension, so the path is the + * specifier resolved against the importer's directory. */ +export function probeNodeFileImportRefusal( + fromFile: string, + specifier: string, + host: NpmGraphHost = realNpmGraphHost, +): NodeImportRefusal | null { + const relative = + specifier.startsWith("./") || + specifier.startsWith("../") || + specifier === "." || + specifier === ".."; + if (!relative && !specifier.startsWith("/")) return null; + const importer = resolve(fromFile); + const target = resolve(dirname(importer), specifier); + if (host.isFile(target) || host.isDirectory(target)) return null; + return { + code: "ERR_MODULE_NOT_FOUND", + message: `Cannot find module '${target}' imported from ${importer}`, + url: pathToFileURL(target).href, + }; +} + export function probeNodeRequireRefusal( fromFile: string, specifier: string, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 7836b8918..e422af2bd 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -555,6 +555,9 @@ export interface StartupCrash { loc: { file: string; start: number; end: number }; /** Node's error code, for the refusals that carry one. */ code?: string; + /** The error's own `url` property: the missing file's URL, for Node's + * ERR_MODULE_NOT_FOUND of an import() whose target does not exist. */ + url?: string; } /** True when a coverage mapping names one exact bare host module. TypeScript diff --git a/packages/compiler/src/ir/build.ts b/packages/compiler/src/ir/build.ts index 77259450c..8a384676f 100644 --- a/packages/compiler/src/ir/build.ts +++ b/packages/compiler/src/ir/build.ts @@ -59,13 +59,15 @@ export function dynUndefinedExpr(loc: SrcLoc): IrExpr { /** An always-throwing Node error with the replaced expression's type. * Kinds: 0 Error, 1 TypeError, 2 RangeError, 5 ReferenceError, 6 EvalError. An empty - * code omits the error code property. */ + * code omits the error code property; a `url` becomes the error's own `url` + * property (Node's ERR_MODULE_NOT_FOUND for a missing file). */ export function nodeThrowExpr( kind: 0 | 1 | 2 | 5 | 6, code: string, message: string, type: IrType, loc: SrcLoc, + url?: string, ): IrExpr { return { kind: "libCall", @@ -74,6 +76,7 @@ export function nodeThrowExpr( { kind: "numLit", value: kind, type: F64, loc }, { kind: "strLit", value: code, type: STRING, loc }, { kind: "strLit", value: message, type: STRING, loc }, + ...(url === undefined ? [] : [{ kind: "strLit", value: url, type: STRING, loc } as const]), ], type, loc, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3287ac54a..3ed177d65 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3764,9 +3764,10 @@ function buildLibFnSignatures() { * arms (ERR_INVALID_THIS receivers, ERR_MISSING_ARGS arity ladders, * the symbol-to-string TypeError): args are [error-kind f64 (the * SCR_ERR_* index: 0 Error, 1 TypeError, 2 RangeError), code (empty = - * no code slot), message]. ALWAYS THROWS catchably; the result type is - * the replaced expression's own (never materialized — the - * global.undefRead pattern). May-throw seed. */ + * no code slot), message], and optionally a fourth string, the error's + * own `url` property (ir/validate.ts accepts it). ALWAYS THROWS + * catchably; the result type is the replaced expression's own (never + * materialized — the global.undefRead pattern). May-throw seed. */ "error.nodeThrow": { argTypes: [F64, STRING, STRING], result: VOID }, /** JS ToString over a dyn value WITH the object protocol (a user * toString/valueOf member is CALLED and its throw propagates; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f591c6dd4..562024e0e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4189,9 +4189,17 @@ function validateFunction( e.fn === "readable.unpipe" || e.fn === "rl.create" || e.fn === "rl.createSignal"; - if (variadic ? e.args.length < sig.argTypes.length : e.args.length !== sig.argTypes.length) { + // error.nodeThrow takes an optional fourth argument: the error's own + // `url` property (a string). + const optionalUrl = e.fn === "error.nodeThrow" && e.args.length === 4; + if ( + variadic + ? e.args.length < sig.argTypes.length + : e.args.length !== sig.argTypes.length && !optionalUrl + ) { err(`libCall ${e.fn}: ${e.args.length} args, expected ${sig.argTypes.length}`, e.loc); } + if (optionalUrl) expectType(e.args[3]!, STRING, `libCall ${e.fn} arg 3`); e.args.forEach((a, i) => { checkExpr(a); const want = sig.argTypes[i]; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json new file mode 100644 index 000000000..bda8f524b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-import-not-found.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index f4bc8db68..1a554ad86 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1710,6 +1710,10 @@ void scr_errdyn_put(ScrError *e, ScrDyn *d); /* retains both sides */ /* Sets an own property on an error: its checked-dynamic node, which casts * and util.inspect read. Moves value. */ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *value); +/* scr_throw_node_coded with an own `url` property (error.nodeThrow's + * fourth argument). Borrows the strings; always throws. */ +SCR_COLD void scr_throw_node_coded_url(double kind, const ScrStr *code, const ScrStr *msg, + const ScrStr *url); ScrDyn *scr_errdyn_view_of(const ScrError *e); /* borrowed or NULL */ /* ToBoolean over the dyn kind (JS-exact); borrowed, never throws. */ bool scr_dyn_truthy(const ScrDyn *d); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index bee032446..6d4e60f8d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -10709,6 +10709,20 @@ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *va scr_dyn_release(view); } +/* scr_throw_node_coded with Node's `url` own property: the + * ERR_MODULE_NOT_FOUND of an import() whose relative target does not + * exist carries the target's file URL. Borrows the strings; always throws + * (catchably — call sites are compiler-emitted pending checks). */ +void scr_throw_node_coded_url(double kind, const ScrStr *code, const ScrStr *msg, const ScrStr *url) { + ScrError *e = scr_error_new((int)kind, (ScrStr *)msg); /* borrowed in */ + if (code->len > 0) { + scr_str_release(e->code); /* NULL-safe */ + e->code = scr_str_retain((ScrStr *)code); + } + scr_error_set_prop(e, "url", 3, scr_dyn_new_str((ScrStr *)url)); + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + /* The error's checked-dynamic node when it has one (borrowed), else NULL: * util.inspect reads its extra own properties. */ ScrDyn *scr_errdyn_view_of(const ScrError *e) { diff --git a/tests/corpus/dynamic-import-not-found.mjs b/tests/corpus/dynamic-import-not-found.mjs new file mode 100644 index 000000000..2f1abf7e4 --- /dev/null +++ b/tests/corpus/dynamic-import-not-found.mjs @@ -0,0 +1,26 @@ +// import() of a specifier nothing resolves rejects with Node's +// ERR_MODULE_NOT_FOUND when the call runs: a package nothing installed +// provides, and a relative path to a file that does not exist, whose error +// also carries the missing file's URL. The program itself loads, and each +// call rejects again when repeated. +const dirs = (text) => text.replace(/\/[^\s']*\//g, "

/"); + +async function probe(label, load) { + try { + await load(); + console.log(label, "loaded"); + } catch (e) { + console.log(label, e instanceof Error, e.name, e.code); + console.log(" ", dirs(e.message)); + console.log(" url:", e.url === undefined ? "none" : dirs(e.url)); + } +} + +console.log("start"); +await probe("bare", () => import("no-such-package-anywhere")); +await probe("bare subpath", () => import("no-such-package-anywhere/sub.js")); +await probe("scoped", () => import("@nobody/no-such-package")); +await probe("relative", () => import("./no-such-file.mjs")); +await probe("relative again", () => import("./no-such-file.mjs")); +await probe("parent", () => import("../no-such-dir/no-such-file.js")); +console.log("end"); diff --git a/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json b/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json new file mode 100644 index 000000000..cbb390ba4 --- /dev/null +++ b/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json @@ -0,0 +1 @@ +{ "plain": 4900, "san": 6100 } From 23e35772643d8ca870b0bfc58e212342110a8ac8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 10:34:42 -0500 Subject: [PATCH 142/193] Compute an array's allocation start as an integer The previous change avoided offsetting a null pointer with a branch in scr_arr_alloc_start. The function is inlined into scr_arr_release and scr_arr_destroy, so the branch changed their control flow, and the functions that changed since the committed runtime profile was collected then held 5.2% of its counts, over runtime-profile.test.ts's 5% limit. The address is now computed as an integer, which is defined for a null block and leaves the callers' control flow as it was. --- packages/runtime/src/scr_array.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index d90a2ec6f..7e2b63d88 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -55,12 +55,12 @@ static size_t scr_arr_prefix(const ScrArr *a) { return (a->spare & SCR_ARR_HAS_EXT) ? SCR_ARR_EXT_PREFIX : 0; } -/* The start of the block the dense storage was allocated as. Without a - * prefix it is the block itself, which is NULL while the array has no - * storage (offsetting a null pointer, even by 0, is undefined). */ +/* The start of the block the dense storage was allocated as. The block is + * NULL while the array has no storage, and offsetting a null pointer, even + * by 0, is undefined: the address is computed as an integer, which keeps the + * callers' control flow (and the runtime profile's view of it) as it was. */ static void *scr_arr_alloc_start(const ScrArr *a) { - size_t prefix = scr_arr_prefix(a); - return prefix ? (char *)scr_arr_block(a) - prefix : (void *)scr_arr_block(a); + return (void *)((uintptr_t)scr_arr_block(a) - scr_arr_prefix(a)); } /* Bytes per dense slot: an 8-byte value or a boolean byte, and its state From 51c9e3153344c68f6ad7e6f353752e7de612d33e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 10:42:30 -0500 Subject: [PATCH 143/193] Skip the receiver bracket for adapters of receiver-free callbacks A signature adapter forwards the ambient receiver to its original, and its closure carries the original's RECEIVER_FREE bit. Calls through the closure's own pointer tested that bit, but the direct entry of a known adapter target bracketed every call, because the adapter itself counts as a receiver reader. The direct entry now tests the bit too. The receiver readers analysis also admits more node kinds that run no user code: optional chains, record presence and absent fields, dynamic boxing, array for-of loops, typed array writes, typed array methods and constructors over typed operands, the module binding check and region intrinsics, performance.now, Atomics operations, string and number library calls over primitives, and new Error(message, options) without an options object. --- .../backend/llvm/constant-callbacks.test.ts | 56 +++++++++ .../compiler/src/backend/llvm/expr-calls.ts | 14 ++- .../backend/llvm/receiver-brackets.test.ts | 68 +++++++++++ packages/compiler/src/ir/receiver-readers.ts | 60 +++++++++- .../corpus/receiver-free-adapter.ts.json | 6 + tests/corpus/receiver-free-adapter.ts | 112 ++++++++++++++++++ .../receiver-free-adapter.ts.json | 1 + 7 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/receiver-brackets.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json create mode 100644 tests/corpus/receiver-free-adapter.ts create mode 100644 tests/harness/corpus-costs/receiver-free-adapter.ts.json diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts index 8bdab0408..a54f3294c 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts @@ -1,7 +1,10 @@ import { expect, test } from "vitest"; import { BOOL, + BYTES_U8, + DYN, F64, + STRING, VOID, funcOf, type IrExpr, @@ -260,6 +263,59 @@ test("receiver elision admits only synchronous receiver-free work", () => { expect(callbackIgnoresReceiver(fn)).toBe(false); }); +test("receiver elision admits library work that runs no user code", () => { + const str: IrExpr = { kind: "strLit", value: "a", type: STRING, loc }; + const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + const dyn = ref("d", DYN); + const ignores = (expr: IrExpr): boolean => { + const fn = fixture().functions[0]!; + fn.body.unshift({ kind: "exprStmt", expr, loc }); + return callbackIgnoresReceiver(fn); + }; + const lib = (fn: string, args: IrExpr[]): IrExpr => + ({ kind: "libCall", fn, args, type: F64, loc }) as IrExpr; + const intrinsic = (name: string, args: IrExpr[]): IrExpr => + ({ kind: "intrinsic", name, args, type: VOID, loc }) as IrExpr; + // String and number members over primitives; a callback operand may run. + expect(ignores(lib("string.lastIndexOfFrom", [str, str, num(0)]))).toBe(true); + expect(ignores(lib("num.sameValue", [num(0), num(1)]))).toBe(true); + expect(ignores(lib("string.lastIndexOf", [str, closure("predicate")]))).toBe(false); + expect(ignores(lib("perf.now", []))).toBe(true); + // `new Error(message, options)` with a primitive message and no options. + const message: IrExpr = { kind: "dynFrom", value: str, type: DYN, loc }; + expect(ignores(lib("error.newOptions", [message, message]))).toBe(true); + expect(ignores(lib("error.newOptions", [message, dyn]))).toBe(false); + // The module binding check only throws; console output may inspect. + const initialized: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + expect(ignores(intrinsic("module.tdzCheck", [initialized, str]))).toBe(true); + expect(ignores(intrinsic("console.log", [dyn]))).toBe(false); + // A typed array built from nothing or a typed source; a dynamic source + // may be an iterable. + const bytes = (source: IrExpr | null): IrExpr => ({ + kind: "bytesNew", + source, + type: BYTES_U8, + loc, + }); + expect(ignores(bytes(null))).toBe(true); + expect(ignores(bytes(dyn))).toBe(false); +}); + +test("receiver elision admits array for-of loops", () => { + const fn = fixture().functions[0]!; + const numbers = { kind: "array", elem: F64 } as const; + const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; + fn.locals.push({ id: "x", name: "x", type: F64, mutable: false }); + fn.body.unshift({ + kind: "forOf", + localId: "x", + iterable: { kind: "arrayLit", elems: [one], type: numbers, loc }, + body: [], + loc, + }); + expect(callbackIgnoresReceiver(fn)).toBe(true); +}); + function callsOf(name: string, callee: string, extra: IrStmt[] = []): IrFunction { return { name, diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 9ad8e5f05..e62fae52a 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -1003,6 +1003,16 @@ export function emitCallExpr( } else if (needsReceiver) B.line("call void @scr_dyn_this_pop()"); return result; }; + // The direct entry of a known target brackets the call when the target + // may observe the receiver. A signature adapter that only forwards the + // receiver to its original carries the original's RECEIVER_FREE bit + // (set where the adapter closure is built), so its entry tests the bit, + // like the entry through the closure's own pointer. + const invokeKnown = (target: string): string => { + const readers = host.receiverReaders; + if (readers.ignores(known!)) return invoke(target, false); + return invoke(target, true, args, readers.forwardsOnly(known!)); + }; const lending = lent && !dynamic ? known!.captures !== undefined @@ -1025,7 +1035,7 @@ export function emitCallExpr( (e.callee.kind === "closure" || (e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId))) ) { - t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!)); + t = invokeKnown(lending ?? direct); if (quiet) pending = false; } else { const fnp = B.tmp(), @@ -1072,7 +1082,7 @@ export function emitCallExpr( B.line(`${matches} = icmp eq ptr ${fn}, ${direct}`); B.condBr(matches, fast, fallback); B.startBlock(fast); - const specialized = invoke(lending ?? direct, !host.receiverReaders.ignores(known!)); + const specialized = invokeKnown(lending ?? direct); // `fast` may have split for exception checks; join from the end. const fastEnd = B.currentLabel(); B.br(done); diff --git a/packages/compiler/src/backend/llvm/receiver-brackets.test.ts b/packages/compiler/src/backend/llvm/receiver-brackets.test.ts new file mode 100644 index 000000000..65ceb8f85 --- /dev/null +++ b/packages/compiler/src/backend/llvm/receiver-brackets.test.ts @@ -0,0 +1,68 @@ +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 "../../index.js"; +import { emitLlvmModule } from "./emitter.js"; + +async function llvmFor(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-receiver-brackets-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + return emitLlvmModule(deserializeModule(await readFile(output, "utf8"))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function body(ir: string, name: string): string { + const match = new RegExp(`^define [^\\n]*@${name}\\(.*?^}`, "ms").exec(ir); + if (!match) throw new Error(`no function ${name}`); + return match[0]; +} + +test("a known signature adapter's direct entry brackets the receiver only when its original reads it", async () => { + const ir = await llvmFor(` +class Node { + kids: Node[] = []; + constructor(readonly kind: number) {} +} +function walk(root: Node, cb: (node: Node, depth: number) => void | "skip" | "stop"): void { + const stack: Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + const verdict = cb(node, stack.length); + if (verdict === "stop") return; + if (verdict === "skip") continue; + for (const kid of node.kids) stack.push(kid); + } +} +class Counter { + count = 0; + run(root: Node): number { + walk(root, (node) => { + if (node.kind === 3) this.count++; + }); + return this.count; + } +} +const root = new Node(0); +root.kids.push(new Node(3)); +console.log(new Counter().run(root)); +`); + const walk = body(ir, "sc_bf_walk"); + const blocks = walk.split(/^(?=[\w.]+:$)/m); + const direct = blocks.find((block) => block.startsWith("callback.direct")); + // The adapter forwards the receiver to the arrow function, which never + // reads it: the direct entry tests the closure's RECEIVER_FREE bit, and + // every receiver push is behind such a test. + expect(direct).toMatch(/ = and i32 %\S+, 16\n/); + const pushes = blocks.filter((block) => block.includes("@scr_dyn_this_push_dyn")); + expect(pushes.length).toBeGreaterThan(0); + for (const block of pushes) expect(block).toMatch(/^receiver\.push\d+:/); +}); diff --git a/packages/compiler/src/ir/receiver-readers.ts b/packages/compiler/src/ir/receiver-readers.ts index 067c474ca..84e2a49d1 100644 --- a/packages/compiler/src/ir/receiver-readers.ts +++ b/packages/compiler/src/ir/receiver-readers.ts @@ -27,6 +27,8 @@ const RECEIVER_FREE_EXPRS = new Set([ "strCmp", "nullish", "orDefault", + "optChain", + "chainRecv", "unionWrap", "unionRetype", "unionNarrow", @@ -35,9 +37,12 @@ const RECEIVER_FREE_EXPRS = new Set([ "unionDisc", "upcast", "downcast", + "dynFrom", "instanceOf", "fieldGet", + "fieldAbsent", "recordGet", + "recordHas", "recordLit", "recordClone", "arrayGet", @@ -58,6 +63,9 @@ const RECEIVER_FREE_EXPRS = new Set([ "setIntrinsic", "mapNew", "setNew", + "bytesIntrinsic", + "bytesNew", + "intrinsic", ]); const RECEIVER_FREE_STMTS = new Set([ @@ -69,6 +77,7 @@ const RECEIVER_FREE_STMTS = new Set([ "while", "doWhile", "for", + "forOf", "switch", "block", "break", @@ -78,6 +87,7 @@ const RECEIVER_FREE_STMTS = new Set([ "arraySet", "arraySetUndefined", "arraySetLength", + "bytesSet", "throw", "rethrow", "tryCatch", @@ -101,7 +111,10 @@ const TRIVIALLY_RECEIVER_FREE = new Set([ kind !== "arrIntrinsic" && kind !== "strIntrinsic" && kind !== "mapIntrinsic" && - kind !== "setIntrinsic", + kind !== "setIntrinsic" && + kind !== "bytesIntrinsic" && + kind !== "bytesNew" && + kind !== "intrinsic", ), ]); @@ -114,8 +127,44 @@ const RECEIVER_FREE_LIB_FNS = new Set([ "math.minArr", "error.new", "error.nodeThrow", + "perf.now", + "atomics.op", ]); +/** Intrinsics that only check a flag or switch the current lifetime + * region: the module binding check (which throws a fixed ReferenceError) + * and the LifetimeRegion operations. */ +const RECEIVER_FREE_INTRINSICS = new Set([ + "module.tdzCheck", + "region.create", + "region.enter", + "region.leave", + "region.release", +]); + +function primitiveOperand(e: IrExpr): boolean { + const kind = e.type.kind; + return kind === "string" || kind === "f64" || kind === "bool"; +} + +/** A dynamic value boxed from a primitive, which converts to a string + * without user code. */ +function primitiveDynOperand(e: IrExpr): boolean { + if (e.kind !== "dynFrom") return false; + const kind = e.value.type.kind; + return kind === "string" || kind === "f64" || kind === "bool" || kind === "undefinedT"; +} + +/** Whether a library call runs no user code: scalar Math, the members + * above, `new Error(message, options)` over a primitive message without an + * options object, and string and number members over primitive operands. */ +function receiverFreeLibCall(e: Extract): boolean { + if (isBorrowSafeMath(e.fn) || RECEIVER_FREE_LIB_FNS.has(e.fn)) return true; + if (e.fn === "error.newOptions") return e.args.every(primitiveDynOperand); + if (e.fn.startsWith("string.") || e.fn.startsWith("num.")) return e.args.every(primitiveOperand); + return false; +} + /** Typed container and string operations. Their runtime paths compare and * copy values but never call back into compiled code, as long as no * operand is a function or a checked value. */ @@ -128,7 +177,12 @@ function dataIntrinsicOperands(e: IrExpr): IrExpr[] | null { case "strIntrinsic": case "mapIntrinsic": case "setIntrinsic": + case "bytesIntrinsic": return [e.receiver, ...e.args]; + // A typed source is copied element by element; a dynamic one may be an + // iterable whose iterator is user code. + case "bytesNew": + return e.source === null ? [] : [e.source]; case "mapNew": case "setNew": return []; @@ -210,7 +264,9 @@ export class AmbientReceiverReaders { return targets.length > 0 && targets.every(edge); } case "libCall": - return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn); + return receiverFreeLibCall(expr); + case "intrinsic": + return RECEIVER_FREE_INTRINSICS.has(expr.name); case "toString": return !opaqueOperand(expr.operand); default: { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json new file mode 100644 index 000000000..79b9bc7f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/receiver-free-adapter.ts" + ], + "diags": [] +} diff --git a/tests/corpus/receiver-free-adapter.ts b/tests/corpus/receiver-free-adapter.ts new file mode 100644 index 000000000..92582bd49 --- /dev/null +++ b/tests/corpus/receiver-free-adapter.ts @@ -0,0 +1,112 @@ +// Callbacks that reach a walker through a signature adapter (the walker's +// callback takes more parameters and returns a verdict), some reading +// `this` and some not. A plain call leaves `this` undefined; an arrow keeps +// its enclosing `this`. +class Tree { + readonly kind: number; + readonly kids: Tree[] = []; + constructor(kind: number) { + this.kind = kind; + } +} + +function walk(root: Tree, visit: (node: Tree, depth: number) => void | "skip" | "stop"): number { + const stack: [Tree, number][] = [[root, 0]]; + let visited = 0; + while (stack.length > 0) { + const [node, depth] = stack.pop()!; + visited++; + const verdict = visit(node, depth); + if (verdict === "stop") break; + if (verdict === "skip") continue; + for (let i = node.kids.length - 1; i >= 0; i--) stack.push([node.kids[i]!, depth + 1]); + } + return visited; +} + +const root = new Tree(0); +for (let i = 1; i <= 4; i++) { + const kid = new Tree(i); + for (let j = 0; j < 3; j++) kid.kids.push(new Tree(i * 10 + j)); + root.kids.push(kid); +} + +const out: string[] = []; + +// An arrow that ignores the receiver and one that captures its class's. +class Counter { + count = 0; + label = "counter"; + run(): string { + walk(root, (node) => { + if (node.kind % 2 === 0) this.count++; + }); + const labels: string[] = []; + walk(root, (node) => { + if (node.kind === 2) return "skip"; + labels.push(`${this.label}:${node.kind}`); + return undefined; + }); + return `${this.count} ${labels.length}`; + } +} +out.push(new Counter().run()); + +// Function expressions that read `this`: a plain call binds undefined. +const receivers: string[] = []; +walk(root, function (this: unknown, node: Tree) { + if (node.kind < 2) receivers.push(this === undefined ? "undefined" : typeof this); +}); +out.push(receivers.join(",")); + +// An unbound method: its `this` is undefined when the walker calls it. +const holder = { + name: "holder", + visit(this: { name: string } | undefined, node: Tree): void { + if (node.kind === 1) out.push(this === undefined ? "unbound" : this.name); + }, +}; +walk(root, holder.visit); +// A bound method keeps its receiver. +walk(root, holder.visit.bind(holder)); + +// Receiver-free bodies with optional chains, record presence and array loops. +type Info = { name?: string; tags: string[] }; +const infos = new Map([ + [1, { name: "one", tags: ["a", "b"] }], + [3, { tags: [] }], +]); +const described: string[] = []; +walk(root, (node) => { + const info = infos.get(node.kind); + let tags = 0; + for (const tag of info?.tags ?? []) tags += tag.length; + if (info !== undefined) described.push(`${info.name?.toUpperCase() ?? "anonymous"}/${"name" in info}/${tags}`); + return node.kind === 3 ? "stop" : undefined; +}); +out.push(described.join(" ")); + +// Walkers with one callback each, which their calls enter directly: one +// reads `this`, the other ignores it. +function eachKid(node: Tree, visit: (kid: Tree, index: number) => boolean | void): number { + let calls = 0; + for (let i = 0; i < node.kids.length; i++) { + calls++; + if (visit(node.kids[i]!, i) === false) break; + } + return calls; +} +const kidReceivers: string[] = []; +eachKid(root, function (this: unknown, kid: Tree) { + kidReceivers.push(`${kid.kind}:${this === undefined ? "undefined" : typeof this}`); +}); +out.push(kidReceivers.join(",")); +function sumKinds(node: Tree, add: (kind: number, depth: number) => number | void): number { + let total = 0; + for (const kid of node.kids) total += add(kid.kind, 1) ?? 0; + return total; +} +let added = 0; +out.push(String(sumKinds(root.kids[2]!, (kind) => (added += kind))), String(added)); + +console.log(out.join("\n")); diff --git a/tests/harness/corpus-costs/receiver-free-adapter.ts.json b/tests/harness/corpus-costs/receiver-free-adapter.ts.json new file mode 100644 index 000000000..74247d8d2 --- /dev/null +++ b/tests/harness/corpus-costs/receiver-free-adapter.ts.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 63000 } From c12f2d64f3e41aede49fe36b5d2a0b5714446956 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 11:11:35 -0500 Subject: [PATCH 144/193] Keep the runtime pack's verified artifacts in a record beside the pack Each build stages the runtime artifacts it links from the runtime pack and hashes each one with SHA-256 unless the build cache records that it was verified with the file identity it still has. A build with an empty cache therefore hashed every artifact again: on Linux x64, 40 objects and 4.3 MB for a Benchmarks Game program, about 25 ms of a 300-1100 ms build. Staging now also reads and writes runtime-pack.verified.json beside the pack. It lists each verified artifact with its digest and file identity (device, inode, size, mtime and ctime), as the cache's record does: - An artifact is trusted only when one of the two records lists it with the manifest's digest and size and the identity its file has during the read. Any change to the file means hashing it again. - A missing, unreadable or malformed record, or a malformed entry, is treated as absent. - The record is written to a new file and renamed over the old one, so a reader sees a whole record. A write merges the record as it is then. - When the pack directory cannot be written, the cache's record is the only one, as before. The runtime pack packages do not publish the file. The native CLI build removes the record its probe builds leave in the bundled pack, and verify-native-cli.mjs rejects a distribution that has one. --- .gitignore | 1 + .../src/backend/runtime-pack-native.test.ts | 189 +++++++++++++++++- .../src/backend/runtime-pack-native.ts | 143 ++++++++++--- scripts/build-native-cli.mts | 7 + scripts/verify-native-cli.mjs | 9 + 5 files changed, 321 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 04c889158..36b158611 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ output/ /packages/runtime-*/artifacts/ /packages/runtime-*/runtime-pack.json /packages/runtime-*/.runtime-pack-* +/packages/runtime-*/runtime-pack.verified.json* # libnode packs: built by build:native, never committed /packages/libnode-*/libnode.so.* /packages/libnode-*/libnode.*.dylib diff --git a/packages/compiler/src/backend/runtime-pack-native.test.ts b/packages/compiler/src/backend/runtime-pack-native.test.ts index 7a8a2e6c4..18975e1f9 100644 --- a/packages/compiler/src/backend/runtime-pack-native.test.ts +++ b/packages/compiler/src/backend/runtime-pack-native.test.ts @@ -1,8 +1,12 @@ import { createHash } from "node:crypto"; import { + chmodSync, + existsSync, mkdirSync, + readdirSync, readFileSync, renameSync, + rmSync, statSync, symlinkSync, unlinkSync, @@ -148,10 +152,12 @@ async function packFixture() { store, ); let stages = 0; - const stage = (selection: NativeRuntimeSelection) => { - const directory = join(root, `stage-${stages++}`); + const stage = (selection: NativeRuntimeSelection, with_: RuntimePackStore = store) => { + const directory = join(tmpdir(), `scriptc-runtime-pack-stage-${process.pid}-${stages++}`); + rmSync(directory, { recursive: true, force: true }); mkdirSync(directory); - return stageNativeRuntimeSelection(selection, directory, store); + dirs.push(directory); + return stageNativeRuntimeSelection(selection, directory, with_); }; return { root, object, manifestText, entries, select, stage }; } @@ -241,3 +247,180 @@ test.skipIf(process.platform === "win32")( expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); }, ); + +/** A store that keeps nothing: a build with an empty cache. */ +function emptyStore(): RuntimePackStore { + const entries = new Map(); + return { + read: (key) => entries.get(key) ?? null, + write: (key, text) => void entries.set(key, text), + }; +} + +const packRecord = (root: string) => join(root, "runtime-pack.verified.json"); +const hashes = () => vi.mocked(createHash).mock.calls.length; + +test("a build with an empty cache trusts the record beside the pack", async () => { + const f = await packFixture(); + const selection = f.select(); + let start = hashes(); + f.stage(selection); + const firstStage = hashes() - start; + const record = JSON.parse(readFileSync(packRecord(f.root), "utf8")); + expect(record.schema).toBe(1); + expect(record.artifacts.map((artifact: { path: string }) => artifact.path)).toEqual([ + "artifacts/base.o", + ]); + start = hashes(); + const staged = f.stage(selection, emptyStore()); + // Only the store key is hashed. + expect(hashes() - start).toBe(firstStage - 1); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); +}); + +test.each(["replaced", "rewritten in place", "resized"])( + "the record beside the pack does not trust an artifact that was %s", + async (change) => { + const f = await packFixture(); + const selection = f.select(); + f.stage(selection); + const before = statSync(f.object); + const changed = change === "replaced" ? f.object + ".new" : f.object; + writeFileSync(changed, "x".repeat(before.size + (change === "resized" ? 1 : 0))); + utimesSync(changed, before.atime, before.mtime); + if (changed !== f.object) renameSync(changed, f.object); + expect(() => f.stage(selection, emptyStore())).toThrow( + "runtime pack artifact hash mismatch: artifacts/base.o", + ); + }, +); + +test("an artifact whose mtime changed is hashed again and recorded again", async () => { + const f = await packFixture(); + const selection = f.select(); + f.stage(selection); + const before = statSync(f.object); + utimesSync(f.object, before.atime, new Date(before.mtimeMs + 5000)); + const start = hashes(); + f.stage(selection, emptyStore()); + // The store key and the artifact. + expect(hashes() - start).toBe(2); + const [entry] = JSON.parse(readFileSync(packRecord(f.root), "utf8")).artifacts; + expect(entry.mtime).toBe(statSync(f.object).mtimeMs); + const again = hashes(); + f.stage(selection, emptyStore()); + expect(hashes() - again).toBe(1); +}); + +test.each([ + ["truncated", () => "{"], + ["null", () => "null"], + ["another schema", (text: string) => text.replace('"schema":1', '"schema":2')], + [ + "an entry with a string ctime", + (text: string) => text.replace(/"ctime":([\d.]+)/, '"ctime":"$1"'), + ], + ["an entry without an inode", (text: string) => text.replace(/"ino":[\d.]+,/, "")], + [ + "another digest", + (text: string) => text.replace(/"sha256":"[0-9a-f]+"/, `"sha256":"${"0".repeat(64)}"`), + ], +])("a record beside the pack that is %s is not trusted", async (_name, rewrite) => { + const f = await packFixture(); + const selection = f.select(); + f.stage(selection); + const text = readFileSync(packRecord(f.root), "utf8"); + const rewritten = rewrite(text); + expect(rewritten).not.toBe(text); + writeFileSync(packRecord(f.root), rewritten); + const start = hashes(); + const staged = f.stage(selection, emptyStore()); + expect(hashes() - start).toBe(2); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); + // The record is written again, whole. + const [entry] = JSON.parse(readFileSync(packRecord(f.root), "utf8")).artifacts; + expect(entry.ino).toBe(statSync(f.object).ino); +}); + +test("an unreadable record beside the pack is not trusted, and staging still succeeds", async () => { + const f = await packFixture(); + const selection = f.select(); + // A directory where the record would be: it can be neither read nor replaced. + mkdirSync(packRecord(f.root)); + const start = hashes(); + f.stage(selection, emptyStore()); + f.stage(selection, emptyStore()); + expect(hashes() - start).toBe(4); + expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]); +}); + +test.skipIf(process.platform === "win32")( + "a pack directory that cannot be written keeps the record in the cache only", + async (context) => { + const f = await packFixture(); + const selection = f.select(); + chmodSync(f.root, 0o555); + try { + // A process that may write any directory cannot run this case. + try { + writeFileSync(join(f.root, "probe"), ""); + rmSync(join(f.root, "probe")); + context.skip(); + } catch { + /* The directory is read-only for this process. */ + } + const store = emptyStore(); + let start = hashes(); + f.stage(selection, store); + expect(hashes() - start).toBe(2); + expect(existsSync(packRecord(f.root))).toBe(false); + expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]); + // The cache's record still answers the next build with that cache. + start = hashes(); + f.stage(selection, store); + expect(hashes() - start).toBe(1); + } finally { + chmodSync(f.root, 0o755); + } + }, +); + +test("writing the record beside the pack keeps the entries another build wrote", async () => { + const f = await packFixture(); + const selection = f.select(); + const other = { + path: "artifacts/other.o", + sha256: "1".repeat(64), + dev: 1, + ino: 2, + size: 3, + mtime: 4, + ctime: 5, + }; + writeFileSync(packRecord(f.root), JSON.stringify({ schema: 1, artifacts: [other] })); + f.stage(selection, emptyStore()); + const record = JSON.parse(readFileSync(packRecord(f.root), "utf8")); + expect(record.artifacts.map((artifact: { path: string }) => artifact.path).sort()).toEqual([ + "artifacts/base.o", + "artifacts/other.o", + ]); + expect( + record.artifacts.find((artifact: { path: string }) => artifact.path === other.path), + ).toEqual(other); + expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]); +}); + +test("a cache that already trusts the artifacts writes the record beside the pack", async () => { + const f = await packFixture(); + const selection = f.select(); + f.stage(selection); + rmSync(packRecord(f.root)); + const start = hashes(); + f.stage(selection); + // The cache's record answers; only the store key is hashed. + expect(hashes() - start).toBe(1); + expect(existsSync(packRecord(f.root))).toBe(true); + const empty = hashes(); + f.stage(selection, emptyStore()); + expect(hashes() - empty).toBe(1); +}); diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index 0fff34455..bd827d963 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -2,9 +2,11 @@ * selected artifact is verified and copied from the same read into private * storage. With a store, a pack's selections and the identities of the * artifacts it verified are kept between builds: an artifact whose file - * identity is unchanged since it was hashed is not hashed again. */ + * identity is unchanged since it was hashed is not hashed again. The + * identities are also kept in a record beside the pack when its directory + * can be written, which builds with an empty store read too. */ import type { NativeOptimization } from "./optimization.js"; -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { closeSync, constants, @@ -13,6 +15,8 @@ import { mkdirSync, openSync, readFileSync, + renameSync, + rmSync, statSync, writeSync, type Stats, @@ -239,21 +243,53 @@ function readVerified( try { const text = store?.read(key) ?? null; if (text === null) return verified; - const stored = JSON.parse(text) as StoredVerification; - if (stored.schema === 1) - for (const artifact of stored.artifacts) verified.set(artifact.path, artifact); + for (const artifact of storedArtifacts(text)) verified.set(artifact.path, artifact); } catch { /* A malformed entry verifies everything again. */ } return verified; } +/** The well-formed entries of a stored verification; none when the text is + * not a stored verification. */ +function storedArtifacts(text: string): VerifiedArtifact[] { + const stored = JSON.parse(text) as Partial | null; + if (stored === null || stored.schema !== 1 || !Array.isArray(stored.artifacts)) return []; + const number = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value); + return stored.artifacts.filter( + (artifact): artifact is VerifiedArtifact => + artifact !== null && + typeof artifact === "object" && + typeof artifact.path === "string" && + typeof artifact.sha256 === "string" && + number(artifact.dev) && + number(artifact.ino) && + number(artifact.size) && + number(artifact.mtime) && + number(artifact.ctime), + ); +} + +/** `known` records `artifact`'s digest and size for a file with `identity`. */ +function verifiedAs( + known: VerifiedArtifact | undefined, + artifact: { sha256: string; size: number }, + identity: FileIdentity, +): boolean { + return ( + known !== undefined && + known.sha256 === artifact.sha256 && + known.size === artifact.size && + sameIdentity(known, identity) + ); +} + function stageArtifact( root: string, stage: string, artifact: RuntimePackArtifact, - verified: Map, - newlyVerified: VerifiedArtifact[], + records: readonly Map[], + verified: VerifiedArtifact[], ): string { const source = join(root, artifact.path); const destination = join(stage, artifact.path); @@ -276,26 +312,17 @@ function stageArtifact( } // The bytes are those last hashed when the file kept the identity it had // then for the whole read. - const known = verified.get(artifact.path); const unchanged = sameIdentity(before, after); const trusted = - known !== undefined && - known.sha256 === artifact.sha256 && - known.size === artifact.size && - unchanged && - known.dev === before.dev && - known.ino === before.ino && - known.size === before.size && - known.mtime === before.mtime && - known.ctime === before.ctime; + unchanged && records.some((record) => verifiedAs(record.get(artifact.path), artifact, before)); if ( bytes.length !== artifact.size || (!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) ) { throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid"); } - if (!trusted && unchanged) - newlyVerified.push({ + if (unchanged) + verified.push({ path: artifact.path, sha256: artifact.sha256, dev: before.dev, @@ -353,10 +380,12 @@ export function stageNativeRuntimeSelection( const packagePath = join(root, "package.json"); const manifestPath = join(root, "runtime-pack.json"); const key = storeKey(JSON.stringify({ schema: 1, verified: root })); - const verified = readVerified(store, key); - const newlyVerified: VerifiedArtifact[] = []; + const cacheRecord = readVerified(store, key); + const packRecordPath = join(root, PACK_VERIFIED_FILE); + const packRecord = readPackVerified(packRecordPath); + const verified: VerifiedArtifact[] = []; const stage = (artifact: RuntimePackArtifact): string => - stageArtifact(root, stageRoot, artifact, verified, newlyVerified); + stageArtifact(root, stageRoot, artifact, [cacheRecord, packRecord], verified); const runtimeObjects = selected.runtime.map(stage); const archives = selected.archives.map(stage); for (const license of licenses) { @@ -372,14 +401,78 @@ export function stageNativeRuntimeSelection( ) { throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid"); } - if (store !== undefined && newlyVerified.length > 0) { - for (const artifact of newlyVerified) verified.set(artifact.path, artifact); - const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] }; + // Each record learns the artifacts verified here that it does not list + // with their current identity. + const missing = (record: Map): VerifiedArtifact[] => + verified.filter((artifact) => !verifiedAs(record.get(artifact.path), artifact, artifact)); + const cacheMissing = missing(cacheRecord); + if (store !== undefined && cacheMissing.length > 0) { + for (const artifact of cacheMissing) cacheRecord.set(artifact.path, artifact); + const entry: StoredVerification = { schema: 1, artifacts: [...cacheRecord.values()] }; try { store.write(key, JSON.stringify(entry)); } catch { /* A failed write only means hashing again next time. */ } } + const packMissing = missing(packRecord); + if (packMissing.length > 0) writePackVerified(packRecordPath, packMissing); return { runtimeObjects, archives, systemLibraries: selected.systemLibraries }; } + +/** The verification record kept beside the runtime pack, so a build with an + * empty cache does not hash the pack's artifacts again. Like the cache's + * record, it trusts an artifact only while its file keeps the identity + * (device, inode, size, mtime, ctime) the record lists. */ +export const PACK_VERIFIED_FILE = "runtime-pack.verified.json"; + +/** The pack record's well-formed entries; none when it is missing, + * unreadable or malformed. */ +function readPackVerified(path: string): Map { + const verified = new Map(); + try { + for (const artifact of storedArtifacts(readFileSync(path, "utf8"))) + verified.set(artifact.path, artifact); + } catch { + /* The artifacts are hashed again. */ + } + return verified; +} + +/** Adds `artifacts` to the pack record: the record as it is now, with these + * entries replacing the ones for the same paths, written to a new file and + * renamed over the record, so a reader sees a whole record. Of two builds + * writing at once, the last rename wins; the entries the other added are + * hashed again by a later build. When the pack directory cannot be written, + * the cache's record is the only one. */ +function writePackVerified(path: string, artifacts: readonly VerifiedArtifact[]): void { + const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + const record = readPackVerified(path); + for (const artifact of artifacts) record.set(artifact.path, artifact); + const entry: StoredVerification = { schema: 1, artifacts: [...record.values()] }; + const bytes = Buffer.from(JSON.stringify(entry)); + const fd = openSync( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o644, + ); + try { + let offset = 0; + while (offset < bytes.length) { + const written = writeSync(fd, bytes, offset, bytes.length - offset); + if (written === 0) throw new Error("the pack record was not written"); + offset += written; + } + } finally { + closeSync(fd); + } + renameSync(temporary, path); + } catch { + try { + rmSync(temporary, { force: true }); + } catch { + /* Cleanup is best effort. */ + } + } +} diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index 6c17b829b..88f86f4fd 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -12,6 +12,7 @@ import { nativeCodegenTarget, } from "../packages/compiler/src/backend/targets.js"; import { tsgoExecutable } from "../packages/compiler/src/frontend/ts7/rpc-api.js"; +import { PACK_VERIFIED_FILE } from "../packages/compiler/src/backend/runtime-pack-native.js"; import { prebuiltLibDiagnosticsText } from "../packages/compiler/src/frontend/ts7/lib-diagnostics.js"; import { stageNativeCliAssets } from "./native-cli-assets.mjs"; import type { NativeToolchainManifest } from "../packages/compiler/src/native/toolchain.js"; @@ -237,6 +238,12 @@ writeFileSync(executable + ".json", JSON.stringify(manifest, null, 2) + "\n"); // for programs without a tsconfig, recorded by the built compiler itself. A // compiler this host cannot run ships without them. const prebuiltKeys = recordPrebuiltLibDiagnostics(executable); +// The probes' builds record the bundled runtime pack's verified artifacts +// beside it, by this host's file identities; the distribution ships without +// that record. +rmSync(join(lib, target.runtimePackPackage.replace("@scriptc/", ""), PACK_VERIFIED_FILE), { + force: true, +}); if (prebuiltKeys === null) { console.warn( "warning: the built compiler did not run here; it ships without prebuilt lib diagnostics", diff --git a/scripts/verify-native-cli.mjs b/scripts/verify-native-cli.mjs index fc94927be..4fa312121 100644 --- a/scripts/verify-native-cli.mjs +++ b/scripts/verify-native-cli.mjs @@ -99,6 +99,15 @@ for (const directory of [distribution, join(distribution, "lib/runtime-sources/v if (readdirSync(directory).some((name) => ["node_modules", ".cache", "seed"].includes(name))) throw new Error(`development files present in ${directory}`); } +// A build's record of the runtime pack's verified artifacts (runtime-pack-native.ts) +// names that host's file identities. +for (const name of readdirSync(join(distribution, "lib"))) { + if ( + name.startsWith("runtime-") && + existsSync(join(distribution, "lib", name, "runtime-pack.verified.json")) + ) + throw new Error(`runtime pack verification record present in lib/${name}`); +} if (process.argv.includes("--run")) { if (target.startsWith("linux-")) { for (const executable of [ From 14babd62315f0fcd1dc49e8f95ef72bae7871f12 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 11:13:10 -0500 Subject: [PATCH 145/193] Call private static function fields and update private static fields through this A call of a function-valued private static field through a receiver other than the class's own name (this.#fn(a) in a static accessor) checks the receiver and calls the stored function with this bound to the receiver, instead of reading a public "#fn" property. this.#x++, --this.#x and this.#x op= v check the receiver where the old value is read and update the field's native storage, instead of a public "#x" property. --- .../frontend/lowering/expressions/updates.ts | 36 ++++++++ .../src/frontend/lowering/private-statics.ts | 84 ++++++++++++++++++- .../test/private-static-receivers.test.ts | 31 +++++++ tests/corpus/js-private-static-receivers.js | 47 +++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 35d5d1a2e..4911a88d7 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -1,4 +1,6 @@ /* Increment, decrement and prefix unary operators, and compound field updates. */ +import { staticPrivateUpdateTarget } from "../private-statics.js"; +import { lowerCompoundValueToTarget } from "./binary.js"; import { indexedUnionArms, lowerIndexedUnionNumberRead, @@ -269,6 +271,19 @@ export function lowerIncDec( } fenceNodeModuleMutation(lowerer, access, "assignment"); if (ts.isPropertyAccessExpression(access)) { + // `this.#x++` on a static private field (private-statics.ts): the + // brand check, then the field's native global. + const staticPrivate = staticPrivateUpdateTarget(lowerer, access); + if (staticPrivate) { + const update = lowerIncDecTarget(lowerer, expr, staticPrivate.target, prefix); + return { + kind: "seqExpr", + stmts: [staticPrivate.brand], + result: update, + type: update.type, + loc, + }; + } const target = staticFieldWriteTarget(lowerer, access) ?? expandoWritableTarget(lowerer, access) ?? @@ -922,6 +937,27 @@ export function lowerFieldCompoundValue( "compound assignment through 'super' (read and write separately)", ); } + // `this.#x op= v` on a static private field (private-statics.ts): the + // brand check, then the field's native global read before the right side. + if ( + ts.isPropertyAccessExpression(access) && + rhsNode !== null && + ts.isBinaryExpression(access.parent) && + access.parent.left === access + ) { + const staticPrivate = staticPrivateUpdateTarget(lowerer, access); + if (staticPrivate) { + const { target } = staticPrivate; + const value = lowerCompoundValueToTarget(lowerer, access.parent, op, target); + return { + kind: "seqExpr", + stmts: [staticPrivate.brand], + result: { kind: "assignExpr", localId: target.id, value, type: target.type, loc }, + type: target.type, + loc, + }; + } + } const body: IrStmt[] = []; const save = (value: IrExpr, name: string): IrExpr => { const local = lowerer.declareHiddenLocal(name, value.type); diff --git a/packages/compiler/src/frontend/lowering/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts index 7a7d56fbb..3ca360209 100644 --- a/packages/compiler/src/frontend/lowering/private-statics.ts +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -14,6 +14,7 @@ import { hasStaticThis } from "./static-this.js"; import type { Lowerer } from "./lowerer.js"; import { lowerClassStaticRead, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; +import { omittedArgFor } from "./call-arguments.js"; /** How a static private name is used: a field read or write, or a method * (or accessor) read or call. V8 words each failure differently. */ @@ -193,10 +194,11 @@ export function lowerStaticPrivateCall( const name = access.name.text; const found = lowerer.findStaticOn(info, name); if (!found) unsupportedStaticPrivate(lowerer, call, name); - if (found.field !== undefined) return null; const value = checkedDynamicReceiver(lowerer, access.expression); if (!value) return null; const loc = locOf(call); + if (found.field !== undefined) + return lowerStaticPrivateFieldCall(lowerer, call, info, name, found.field, value); const brand = staticPrivateBrandCheck(lowerer, info, "method", name, value, loc); let result: IrExpr | null; if (access.expression.kind === ts.SyntaxKind.ThisKeyword) { @@ -247,6 +249,62 @@ export function lowerStaticPrivateCall( }; } +/** `R.#f(args)` for a function-valued static private field on a + * checked-dynamic receiver: the receiver, the brand check (PrivateGet's), + * then a call through the field's value with `this` bound to the receiver, + * as JavaScript calls a private field's function. */ +function lowerStaticPrivateFieldCall( + lowerer: Lowerer, + call: ts.CallExpression, + info: ClassInfo, + name: string, + field: ClassInfo["staticFields"][number], + value: IrExpr, +): IrExpr { + const fieldType = field.type; + if (fieldType.kind !== "func") { + return lowerer.unsupported( + "SC1090", + call, + `calling the private static field '${name}' when it does not hold a function of a known type`, + ); + } + const loc = locOf(call); + const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); + const args = call.arguments.nodes.map((arg, index) => + lowerer.lowerExprExpecting(arg, fieldType.params[index]), + ); + for (let index = args.length; index < fieldType.params.length; index++) { + const absent = omittedArgFor(lowerer, fieldType.params[index]!, loc); + if (!absent) { + lowerer.unsupported( + "SC1090", + call, + "calls omitting a non-optional parameter of the callee's type", + ); + } + args.push(absent); + } + const result: IrExpr = { + kind: "callValue", + callee: varRef(field.globalId, fieldType, loc), + receiver: varRef(receiver.id, DYN, loc), + args, + type: fieldType.ret, + loc, + }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiver.id, init: value, loc }, + staticPrivateBrandCheck(lowerer, info, "read", name, varRef(receiver.id, DYN, loc), loc), + ], + result, + type: result.type, + loc, + }; +} + /** `R.#x = v` for a static private field on a checked-dynamic receiver: * the receiver, then the value, then the brand check (PrivateSet checks * after the right side runs), then the native global. */ @@ -295,3 +353,27 @@ export function lowerStaticPrivateAssignment( loc, }; } + +/** The update target of `R.#x++`, `--R.#x` or `R.#x op= v` for a static + * private field on a checked-dynamic receiver: the brand check, which runs + * where PrivateGet reads the old value (before the right side), and the + * field's native global. The write after it needs no second check: the + * receiver already passed. Null for other targets. */ +export function staticPrivateUpdateTarget( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): { brand: IrStmt; target: { id: string; type: IrType } } | null { + if (!ts.isPrivateIdentifier(access.name) || access.questionDotToken) return null; + const info = staticPrivateOwner(lowerer, access.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(access.expression) === info) return null; + const name = access.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found?.field) unsupportedStaticPrivate(lowerer, access, name); + const value = checkedDynamicReceiver(lowerer, access.expression); + if (!value) return null; + return { + brand: staticPrivateBrandCheck(lowerer, info, "read", name, value, locOf(access)), + target: { id: found.field.globalId, type: found.field.type }, + }; +} diff --git a/packages/compiler/test/private-static-receivers.test.ts b/packages/compiler/test/private-static-receivers.test.ts index 7295e5592..c9260f199 100644 --- a/packages/compiler/test/private-static-receivers.test.ts +++ b/packages/compiler/test/private-static-receivers.test.ts @@ -67,3 +67,34 @@ test("the declaring class's own name reads native storage without a check", asyn expect(ir.functions.some((name) => name.startsWith("%private.static."))).toBe(false); expect(ir.keys).toEqual([]); }); + +test("a function-valued static private field called through `this` calls the stored function", async () => { + const ir = await lowered( + `class C { + static #fn = function (a) { return a + 1; }; + static get viaGetter() { return this.#fn(1); } + } + console.log(C.viaGetter); + `, + ); + expect(ir.callees).toContain("%private.static.read:C:#fn"); + expect(ir.keys).toEqual([]); +}); + +test("updates of a static private field through `this` check the brand and keep native storage", async () => { + const ir = await lowered( + `class C { + static #n = 1; + static get bump() { + this.#n++; + --this.#n; + this.#n += 2; + return this.#n; + } + } + console.log(C.bump); + `, + ); + expect(ir.callees).toContain("%private.static.read:C:#n"); + expect(ir.keys).toEqual([]); +}); diff --git a/tests/corpus/js-private-static-receivers.js b/tests/corpus/js-private-static-receivers.js index 33b0acf97..4dc626cf6 100644 --- a/tests/corpus/js-private-static-receivers.js +++ b/tests/corpus/js-private-static-receivers.js @@ -117,3 +117,50 @@ for (const other of [{}, 1, undefined]) { Generators.agen(4) .next() .then((step) => console.log("async generator", step.value, step.done)); + +// A function-valued private static field called through `this` runs with +// `this` bound to the receiver; a receiver other than the class throws the +// field read's TypeError. +class Calls { + static #fn = function (a) { + return (this === Calls ? "self " : "other ") + a; + }; + static #arrow = (a) => "arrow " + a; + static get viaGetter() { + return this.#fn(2) + " " + this.#arrow(2); + } + static viaOther(other, a) { + return other.#fn(a); + } + static get fn() { + return this.#fn; + } +} +console.log("field call", Calls.viaGetter, Calls.viaOther(Calls, 3)); +try { + Calls.viaOther({}, 4); +} catch (error) { + console.log("field call", error.constructor.name, error.message); +} + +// Updates of a private static field through `this` update its storage. +class Updates { + static #n = 1; + static #s = "a"; + static get bump() { + const a = this.#n++; + const b = ++this.#n; + const c = this.#n--; + this.#n += 10; + const d = (this.#n *= 2); + this.#s += "b"; + return [a, b, c, d, this.#n, this.#s].join(","); + } +} +console.log("updates", Updates.bump, Updates.bump, Object.hasOwn(Updates, "#n")); +class SubUpdates extends Updates {} +try { + SubUpdates.bump; +} catch (error) { + console.log("updates", error.constructor.name, error.message); +} From 7a9ad0140fbf8d93d1159ca8326530e05542e842 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 11:15:45 -0500 Subject: [PATCH 146/193] Read omitted optional fields of partial assertions as undefined A partial record assertion's view has no slot for any field the literal omits. An omitted optional field now reads as undefined and answers false to in, as in Node, instead of reaching a slot the view does not have. The in test's join takes its predecessor from the block the field load ends in. A write of an omitted field evaluates the receiver and the value before the presence check, so the right side's side effects run before the TypeError. The lowerer unit tests' program lists its source files, which the partial-assertion scan reads. --- .../src/backend/llvm/expr-primitives.ts | 4 +- .../frontend/lowering/expressions/updates.ts | 18 +++++- .../src/frontend/lowering/lowerer.test.ts | 6 +- .../frontend/lowering/partial-assertions.ts | 59 +++++++++++-------- .../record-partial-assertion-lowering.test.ts | 49 +++++++++++++++ tests/corpus/record-partial-assertion.ts | 26 ++++++++ 6 files changed, 135 insertions(+), 27 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 44db91185..439f49919 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -1531,10 +1531,12 @@ export function emitRecordExpr( const absent = host.fieldAbsentTest(v, unionId); const present = B.tmp(); B.line(`${present} = xor i1 ${absent}, true`); + // The field load can end in a later block (a view's slot read). + const checked = B.currentLabel(); B.br(join); B.startBlock(join); const answer = B.tmp(); - B.line(`${answer} = phi i1 [ false, %${from} ], [ ${present}, %${check} ]`); + B.line(`${answer} = phi i1 [ false, %${from} ], [ ${present}, %${checked} ]`); return { name: answer, type: e.type }; } case "fieldAbsent": diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 1b151fa9f..30ea81ae2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -902,7 +902,9 @@ export function fieldSetStmt( // A field some partial record assertion leaves unwritten (a partial // record view, partial-assertions.ts): a view cannot grow a slot the // asserted literal never wrote, so the write throws the catchable - // TypeError (Node would add the property — a documented divergence). + // TypeError (Node would add the property — a documented divergence). The + // receiver and the value are evaluated first, as JavaScript does, and the + // presence test runs where the write would. const refused = partialFieldWriteError(lowerer, target.shapeId, target.field, loc); if (refused !== null) { const obj = target.obj; @@ -910,10 +912,15 @@ export function fieldSetStmt( ? null : lowerer.declareHiddenLocal("%partialWrite", obj.type); const ref = stable ? varRef(stable.id, obj.type, loc) : obj; + const saved = isSafeToRepeat(value) + ? null + : lowerer.declareHiddenLocal("%partialWriteValue", value.type); + const written = saved ? varRef(saved.id, value.type, loc) : value; return { kind: "block", body: [ ...(stable ? [{ kind: "varDecl" as const, localId: stable.id, init: obj, loc }] : []), + ...(saved ? [{ kind: "varDecl" as const, localId: saved.id, init: value, loc }] : []), { kind: "if", cond: { @@ -928,7 +935,14 @@ export function fieldSetStmt( else_: [{ kind: "exprStmt", expr: refused, loc }], loc, }, - { kind: "recordSet", obj: ref, shapeId: target.shapeId, field: target.field, value, loc }, + { + kind: "recordSet", + obj: ref, + shapeId: target.shapeId, + field: target.field, + value: written, + loc, + }, ], loc, }; diff --git a/packages/compiler/src/frontend/lowering/lowerer.test.ts b/packages/compiler/src/frontend/lowering/lowerer.test.ts index 6f1c1c555..da2f7da7f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.test.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.test.ts @@ -17,7 +17,11 @@ import { classMethodValue } from "./class-method-values.js"; const loc = { file: "island.ts", start: 0, end: 1 }; const record = (shapeId: string): IrType => ({ kind: "record", shapeId }); const context = () => - new Lowerer({ getTypeChecker: () => ({}) } as Program, { fileName: loc.file } as SourceFile, []); + new Lowerer( + { getTypeChecker: () => ({}), getImplementationSourceFiles: () => [] } as unknown as Program, + { fileName: loc.file } as SourceFile, + [], + ); test("reuses complete copy routes without repeating discriminator planning", () => { const lowerer = context(); diff --git a/packages/compiler/src/frontend/lowering/partial-assertions.ts b/packages/compiler/src/frontend/lowering/partial-assertions.ts index 12d3c32b7..5f8ecc253 100644 --- a/packages/compiler/src/frontend/lowering/partial-assertions.ts +++ b/packages/compiler/src/frontend/lowering/partial-assertions.ts @@ -10,12 +10,13 @@ * not declare are absent. Reflection through the view is exact. * * This module finds those sites and answers, per target shape, which - * required fields some asserted literal leaves unwritten. The lowering - * consults it for member reads and writes, destructuring and `in`, so an - * absent field's read throws the catchable TypeError (Node yields - * undefined — a documented divergence) instead of reaching a slot the view - * does not have. Computed once for the whole program, on first use, like - * the untyped-collection inference (js-collection-types.ts). */ + * fields some asserted literal leaves unwritten. The lowering consults it + * for member reads and writes, destructuring and `in`, so a read of an + * absent optional field yields undefined, as in Node, and a read of an + * absent required field throws the catchable TypeError (Node yields + * undefined — a documented divergence), instead of reaching a slot the + * view does not have. Computed once for the whole program, on first use, + * like the untyped-collection inference (js-collection-types.ts). */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; @@ -32,7 +33,7 @@ import { } from "../../ir/ir.js"; /** One site's facts: the asserted record type, the literal's keys in - * source order, and the required B fields the literal does not write. */ + * source order, and the B fields the literal does not write. */ export interface PartialAssertionSite { readonly target: IrType & { kind: "record" }; readonly keys: readonly string[]; @@ -48,7 +49,7 @@ interface PartialTarget { const NO_FIELDS: ReadonlySet = new Set(); -/** The required fields some asserted literal leaves unwritten on shapes of +/** The fields some asserted literal leaves unwritten on shapes of * `shapeId`; empty when no partial assertion produces that shape. */ export function partialAssertionFields(lowerer: Lowerer, shapeId: string): ReadonlySet { return partialTargetOf(lowerer, shapeId)?.missing ?? NO_FIELDS; @@ -108,22 +109,26 @@ export function partialAssertionSite( // map, which a view has no slots for. if (!shape.fields.some((f) => f.name === key)) return null; } + let required = false; for (const field of shape.fields) { if (keys.includes(field.name)) continue; - // Only a REQUIRED field can be missing: an undefined-armed slot holds - // the field's absent state, which the view keeps. - if (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0) - continue; + // The view has no slot for any field the literal does not write, an + // optional one included. missing.add(field.name); + if (!(field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0)) + required = true; } - if (missing.size === 0) return null; + // Omitting only optional fields needs no view: the literal at the + // asserted type's layout holds their absent state. + if (!required) return null; return { target, keys, missing, text: lowerer.checker.typeToString(targetTs) }; } /** The checked read of `field` through a value of `shapeId` that a partial - * assertion may have produced, or null when the field is always present. - * `obj` is read twice (the presence test and the read), so it must be safe - * to repeat. */ + * assertion may have produced (undefined for an absent optional field, a + * TypeError for an absent required one), or null when the field is always + * present. `obj` is read twice (the presence test and the read), so it must + * be safe to repeat. */ export function partialFieldRead( lowerer: Lowerer, obj: IrExpr, @@ -134,17 +139,25 @@ export function partialFieldRead( ): IrExpr | null { const target = partialTargetOf(lowerer, shapeId); if (target === null || !target.missing.has(field)) return null; + // An optional field the literal omits reads as undefined, as in Node. + const optional = + fieldType.kind === "union" && lowerer.armTag(fieldType.unionId, UNDEFINED_T) >= 0; return { kind: "ternary", cond: { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }, then: { kind: "recordGet", obj, shapeId, field, type: fieldType, loc }, - else_: nodeThrowExpr( - 1, - "", - `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, - fieldType, - loc, - ), + else_: optional + ? lowerer.coerceToExpected( + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + fieldType, + ) + : nodeThrowExpr( + 1, + "", + `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, + fieldType, + loc, + ), type: fieldType, loc, }; diff --git a/packages/compiler/test/record-partial-assertion-lowering.test.ts b/packages/compiler/test/record-partial-assertion-lowering.test.ts index e7bad67b6..0ba5fb70d 100644 --- a/packages/compiler/test/record-partial-assertion-lowering.test.ts +++ b/packages/compiler/test/record-partial-assertion-lowering.test.ts @@ -114,3 +114,52 @@ test("a write of a field a partial assertion never wrote throws before storing", }, ]); }); + +test("a read of an optional field a partial assertion omits yields undefined", async () => { + const nodes = await moduleNodes<{ + kind: string; + cond?: { kind: string; field?: string }; + else_?: { kind: string; fn?: string }; + }>( + `interface N { content: string; note?: string } + function read(value: N): string | undefined { return value.note; } + read({ content: "c" } as N); + read({} as N); + export {}; + `, + ); + const guard = nodes.find( + (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "note", + ); + expect(guard).toBeDefined(); + expect(guard!.else_?.fn).not.toBe("error.nodeThrow"); +}); + +test("a write of a field a partial assertion never wrote evaluates the value before the check", async () => { + const nodes = await moduleNodes<{ + kind: string; + localId?: string; + body?: { kind: string; localId?: string; cond?: { kind: string; field?: string } }[]; + }>( + `${TR}function value(): string { return "x"; } + function write(target: TR): void { target.content = value(); } + write({ truncated: true } as TR); + export {}; + `, + ); + const body = nodes + .filter((n) => n.kind === "block") + .map((n) => n.body ?? []) + .find((stmts) => + stmts.some( + (s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content", + ), + ); + expect(body).toBeDefined(); + const saved = body!.findIndex( + (s) => s.kind === "varDecl" && s.localId?.includes("partialWriteValue") === true, + ); + const check = body!.findIndex((s) => s.kind === "if"); + expect(saved).toBeGreaterThanOrEqual(0); + expect(saved).toBeLessThan(check); +}); diff --git a/tests/corpus/record-partial-assertion.ts b/tests/corpus/record-partial-assertion.ts index 6a9b2c93b..e991299c4 100644 --- a/tests/corpus/record-partial-assertion.ts +++ b/tests/corpus/record-partial-assertion.ts @@ -22,4 +22,30 @@ console.log(render(false, "out")); const full: TruncationResult = { content: "c", truncated: false, truncatedBy: null, totalLines: 3, firstLineExceedsLimit: false }; const p = new Panel(); p.setComplete(full); console.log(p.summary(), p.dump(), p.keys(), p.hasContent(), full.totalLines + 1); + +// An optional field the literal omits reads as undefined, and `in` answers +// false for it. +interface Note { content: string; truncated: boolean; note?: string; count?: number } +function note(flag: boolean): Note { + return flag ? ({ truncated: true } as Note) : { content: "c", truncated: false, note: "n" }; +} +const omitted = note(true); +console.log(omitted.note, omitted.note ?? "none", omitted.count === undefined, typeof omitted.note); +console.log("note" in omitted, "truncated" in omitted, "content" in omitted); +const written = note(false); +console.log(written.note, "note" in written, "count" in written); + +// A write of a field the literal omits evaluates its right side first. +const order: string[] = []; +function rhs(): string { + order.push("rhs"); + return "v"; +} +const target = note(true); +try { + target.content = rhs(); +} catch { + order.push("caught"); +} +console.log(order[0]); export {}; From 8ccaea7e5cf9fb002979ac4532c0b5ee9410a08b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 13:14:45 -0500 Subject: [PATCH 147/193] Keep builtin methods of untyped receivers whose name a class declares as a getter A call whose name some class declares only as a getter reads the member first on instances of those classes, and keeps the runtime's method dispatch for every other receiver, so n.toFixed(2) still formats a number when a class declares get toFixed(). This replaces the list of builtin method names. --- .../lowering/class-dynamic-dispatch.ts | 168 +++++++----------- tests/corpus/dynamic-call-callee-order.mjs | 17 +- 2 files changed, 76 insertions(+), 109 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 1870f87ce..7d170d24a 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -93,95 +93,8 @@ import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js"; import { STREAM_OPERATORS, operatorSignalOptions, reduceSignalSetup } from "./lower-stream.js"; import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js"; -/** Methods that builtin prototypes define (arrays, strings, collections, - * promises, functions, regular expressions, iterators, Object.prototype): - * a call of one of these names through an untyped receiver keeps the - * runtime's method dispatch, which answers them, even when a class - * declares a getter of the same name. */ -const BUILTIN_METHOD_NAMES: ReadonlySet = new Set([ - "add", - "apply", - "at", - "bind", - "call", - "catch", - "charAt", - "charCodeAt", - "clear", - "codePointAt", - "concat", - "copyWithin", - "delete", - "endsWith", - "entries", - "every", - "exec", - "fill", - "filter", - "finally", - "find", - "findIndex", - "findLast", - "findLastIndex", - "flat", - "flatMap", - "forEach", - "get", - "has", - "hasOwnProperty", - "includes", - "indexOf", - "isPrototypeOf", - "join", - "keys", - "lastIndexOf", - "localeCompare", - "map", - "match", - "matchAll", - "next", - "normalize", - "padEnd", - "padStart", - "pop", - "propertyIsEnumerable", - "push", - "reduce", - "reduceRight", - "repeat", - "replace", - "replaceAll", - "return", - "reverse", - "search", - "set", - "shift", - "slice", - "some", - "sort", - "splice", - "split", - "startsWith", - "substr", - "substring", - "test", - "then", - "throw", - "toLocaleString", - "toLowerCase", - "toReversed", - "toSorted", - "toSpliced", - "toString", - "toUpperCase", - "trim", - "trimEnd", - "trimStart", - "unshift", - "valueOf", - "values", - "with", -]); +/** The receiver local of a call rewritten for a getter-only name. */ +const GETTER_RECEIVER = "%dispatch.getter.receiver."; const STREAM_METHODS = new Set([ "on", @@ -1987,20 +1900,29 @@ export class ClassDynamicDispatch { byMethod.set(method, matching); return matching; }; - // A name some class declares as a getter, and no class as a method: the - // call reads the member (running the getter) before it evaluates the - // arguments, as JavaScript reads the callee first, and then calls what - // it read. The runtime reads fields and other own properties of class - // instances that early itself (scr_dyn_prepare_method). - const getterReceiver = (method: string): boolean => - !BUILTIN_METHOD_NAMES.has(method) && - this.propertyReceivers(lowerer, true).some(({ info }) => - findMethodOn(lowerer, info, `get:${method}`), - ); - const getterCall = (fn: IrFunction, invoke: IrExpr & { kind: "dynInvoke" }): IrExpr => { + // A name some class declares as a getter, and no class as a method: on + // an instance of such a class the call reads the member (running the + // getter) before it evaluates the arguments, as JavaScript reads the + // callee first, and then calls what it read. Any other receiver keeps + // the runtime's method dispatch, which answers builtin methods of the + // same name (`n.toFixed(2)` while a class declares `get toFixed()`). + // The runtime reads fields and other own properties of class instances + // that early itself (scr_dyn_prepare_method). + const getterCapsules = (method: string): string[] => { + const keys = new Set(); + for (const { info, capsule } of this.propertyReceivers(lowerer, true)) + if (findMethodOn(lowerer, info, `get:${method}`)) + keys.add(typeKey({ kind: "object", className: capsule.def.name })); + return [...keys]; + }; + const getterCall = ( + fn: IrFunction, + invoke: IrExpr & { kind: "dynInvoke" }, + capsules: readonly string[], + ): IrExpr => { const loc = invoke.loc; const receiverLocal = { - id: `%dispatch.receiver.${fn.locals.length}`, + id: `${GETTER_RECEIVER}${fn.locals.length}`, name: "receiver", type: DYN, mutable: false, @@ -2014,10 +1936,27 @@ export class ClassDynamicDispatch { }; fn.locals.push(calleeLocal); const receiver = varRef(receiverLocal.id, DYN, loc); - return { + const isCapsule = (key: string): IrExpr => ({ + kind: "libCall", + fn: "dyn.typedRefIs", + args: [receiver, { kind: "strLit", value: key, type: STRING, loc }], + type: BOOL, + loc, + }); + const cond = capsules.slice(1).reduce( + (rest, key) => ({ + kind: "ternary", + cond: isCapsule(key), + then: { kind: "boolLit", value: true, type: BOOL, loc }, + else_: rest, + type: BOOL, + loc, + }), + isCapsule(capsules[0]!), + ); + const read: IrExpr = { kind: "seqExpr", stmts: [ - { kind: "varDecl", localId: receiverLocal.id, init: invoke.recv, loc }, { kind: "varDecl", localId: calleeLocal.id, @@ -2044,6 +1983,20 @@ export class ClassDynamicDispatch { type: DYN, loc, }; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: receiverLocal.id, init: invoke.recv, loc }], + result: { + kind: "ternary", + cond, + then: read, + else_: { ...invoke, recv: receiver }, + type: DYN, + loc, + }, + type: DYN, + loc, + }; }; const candidateSets = new Map>(); const candidateSet = (method: string): ReadonlySet => { @@ -3117,9 +3070,12 @@ export class ClassDynamicDispatch { expr.kind === "dynInvoke" && candidates(invoke.method).length === 0 && !staticCandidates(invoke.method) && - getterReceiver(invoke.method) - ) - return getterCall(fn, expr); + // The other receivers' call this rewrite left in place. + !(expr.recv.kind === "varRef" && expr.recv.localId.startsWith(GETTER_RECEIVER)) + ) { + const capsules = getterCapsules(invoke.method); + if (capsules.length > 0) return getterCall(fn, expr, capsules); + } if ( !invoke || (candidates(invoke.method).length === 0 && diff --git a/tests/corpus/dynamic-call-callee-order.mjs b/tests/corpus/dynamic-call-callee-order.mjs index 20190db84..39ea01c9e 100644 --- a/tests/corpus/dynamic-call-callee-order.mjs +++ b/tests/corpus/dynamic-call-callee-order.mjs @@ -97,8 +97,9 @@ try { } catch (e) { console.log(e instanceof TypeError, e.message); } -// A class field and a getter named like array methods do not change calls -// of those methods on arrays. +// Fields and getters named like builtin methods: calls of those methods on +// arrays and numbers through untyped receivers keep the builtin, and the +// getters still answer for class instances. class Material { constructor() { this.map = null; @@ -106,8 +107,18 @@ class Material { get filter() { return () => "material-filter"; } + get toFixed() { + return (digits) => `material-${digits}`; + } } function doubled(items) { return items.map((x) => x * 2).filter((x) => x > 2); } -console.log(new Material().map, new Material().filter(), doubled([1, 2, 3]).join(",")); +function fixed(value, digits) { + return value.toFixed(digits); +} +function filtered(value) { + return value.filter(() => true); +} +console.log(new Material().map, doubled([1, 2, 3]).join(","), filtered([4, 5]).join(",")); +console.log(fixed(3.14159, 2), fixed(new Material(), 3), filtered(new Material())); From 62943d74600cb0689bf3c12aa259c079c9670000 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 13:31:39 -0500 Subject: [PATCH 148/193] Search wrapper graphs once, brand the wrapper prototypes, read call members once - The wrapper check before JSON, util.inspect, structuredClone and deep equality searched the value's graph recursively with only a depth limit, so an object with two references to itself took exponentially many calls, and a wrapper more than 64 levels deep was not found. The search now keeps a set of the objects it reached and an explicit stack: each object and array is searched once, at any depth. - String.prototype, Number.prototype and Boolean.prototype are wrappers of "", 0 and false, as in Node, so their valueOf and toString answer the primitive instead of throwing the brand TypeError. String.prototype has its own `length` of 0. - A string-named method call on a dynamic receiver in a program with wrappers read the member twice: once to check for the builtin String.prototype method and once for the call. The call now reads it once, and wrapper.isStringMethod checks that value. - createsWrappers is listed with the lowerer's first-use caches that the unit journal leaves out. The wrapper corpus program covers the prototypes' values, a getter read once per call and console.log of a 40-level shared graph. The refusal test covers a wrapper 100 levels deep, a wrapper under a shared graph and util.inspect of a cyclic value. --- .../src/frontend/lowering/lower-calls.ts | 49 ++--- .../compiler/src/frontend/lowering/lowerer.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 3 +- .../compiler/src/ir/builtin-signatures.ts | 7 +- packages/runtime/src/scr_dyn.h | 2 +- packages/runtime/src/scr_js_wrapper.c | 200 ++++++++++++------ tests/corpus/js-primitive-wrappers.js | 26 +++ .../primitive-wrapper-refusals.test.ts | 19 +- 8 files changed, 212 insertions(+), 95 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 827e69b58..85c73cb38 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4303,6 +4303,7 @@ function lowerStringOrMemberCall( // In a program that creates wrapper objects, a receiver whose member is // the builtin String.prototype method runs the string lowering on // ToString of the receiver, after the arguments, as the builtin does. + // The member is read once: the check inspects the value the call read. const wrappers = (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)); const builtinLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallStringMethod", BOOL) : null; const isString: IrExpr = builtinLocal @@ -4313,35 +4314,12 @@ function lowerStringOrMemberCall( const callee = varRef(calleeLocal.id, DYN, loc); const stmts: IrStmt[] = [ { kind: "varDecl", localId: receiverLocal.id, init: recv, loc }, - ...(builtinLocal - ? [ - { - kind: "varDecl" as const, - localId: builtinLocal.id, - init: { - kind: "logical" as const, - op: "||" as const, - left: receiverIsString, - right: { - kind: "libCall" as const, - fn: "wrapper.isStringMethod" as const, - args: [receiver, nameLit], - type: BOOL, - loc, - }, - type: BOOL, - loc, - }, - loc, - }, - ] - : []), { kind: "varDecl", localId: calleeLocal.id, init: { kind: "ternary", - cond: isString, + cond: receiverIsString, then: dynUndefinedExpr(loc), else_: { kind: "dynKeyGet", @@ -4355,6 +4333,29 @@ function lowerStringOrMemberCall( }, loc, }, + ...(builtinLocal + ? [ + { + kind: "varDecl" as const, + localId: builtinLocal.id, + init: { + kind: "logical" as const, + op: "||" as const, + left: receiverIsString, + right: { + kind: "libCall" as const, + fn: "wrapper.isStringMethod" as const, + args: [callee, nameLit], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + loc, + }, + ] + : []), ]; const args = call.arguments.nodes.map((node) => { const lowered = lowerer.lowerExpr(node); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3096fdcdc..8334c0275 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4478,6 +4478,7 @@ export class Lowerer { // caches made when first needed "nanCodedReads", "mentionsProcessVersions", + "createsWrappers", "stringIndexBoundsCache", "functionLocalSubclasses", "socketEncodingReachable", diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 5078f6f11..0e722de49 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -755,11 +755,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.toString", "dyn.valueOf", // Primitive wrapper objects (scr_js_wrapper.c): the conversions run - // user hooks, member reads run getters, and the guard refuses. + // user hooks and the guard refuses. "wrapper.new", "wrapper.toObject", "wrapper.objectTag", - "wrapper.isStringMethod", "wrapper.thisString", "wrapper.guard", "wrapper.guardList", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index eeb986bf6..922c887bf 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3703,9 +3703,10 @@ function buildLibFnSignatures() { * consumer). guardList checks each element. */ "wrapper.guard": { argTypes: [DYN, STRING], result: DYN }, "wrapper.guardList": { argTypes: [arrayOf(DYN), STRING], result: arrayOf(DYN) }, - /** Whether the member `name` of the value is the builtin - * String.prototype method (scr_js_wrapper_is_string_method), and the - * method's receiver, ToString of the value (scr_js_wrapper_this_string). */ + /** Whether a member value a call read as `name` is the builtin + * String.prototype method of that name (scr_js_wrapper_is_string_method), + * and the method's receiver, ToString of the value + * (scr_js_wrapper_this_string). */ "wrapper.isStringMethod": { argTypes: [DYN, STRING], result: BOOL }, "wrapper.thisString": { argTypes: [DYN, STRING], result: DYN }, "fs.callbackValue": { argTypes: [STRING], result: DYN }, diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index d2ee9ccb3..6cfe81d0f 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1463,7 +1463,7 @@ ScrStr *scr_js_wrapper_object_tag(ScrDyn *value); bool scr_js_wrapper_type_is(ScrDyn *value, ScrStr *probe); ScrDyn *scr_js_wrapper_guard(ScrDyn *value, ScrStr *consumer); ScrArr *scr_js_wrapper_guard_list(ScrArr *values, ScrStr *consumer); -bool scr_js_wrapper_is_string_method(ScrDyn *value, ScrStr *name); +bool scr_js_wrapper_is_string_method(ScrDyn *member, ScrStr *name); ScrDyn *scr_js_wrapper_this_string(ScrDyn *value, ScrStr *name); ScrDyn *scr_dyn_util_type_value(const ScrDyn *value, const ScrStr *probe); void scr_dyn_arr_push(ScrDyn *arr, ScrDyn *item); diff --git a/packages/runtime/src/scr_js_wrapper.c b/packages/runtime/src/scr_js_wrapper.c index da218a173..ea60f7b37 100644 --- a/packages/runtime/src/scr_js_wrapper.c +++ b/packages/runtime/src/scr_js_wrapper.c @@ -263,36 +263,17 @@ static const ScrJsWrapperMember scr_js_wrapper_boolean_members[] = { {"toString", 0}, {"valueOf", 0}, {NULL, 0}, }; -/* The kind's prototype object (borrowed), built on first use with - * `constructor` (the compiled constructor value) and the members above. - * Its own prototype is Object.prototype. */ -static ScrDyn *scr_js_wrapper_prototype(int kind, ScrDyn *constructor) { - if (scr_js_wrapper_prototypes[kind]) return scr_js_wrapper_prototypes[kind]; - if (!scr_js_wrapper_cleanup_registered) { - scr_atexit(scr_js_wrapper_cleanup); - scr_js_wrapper_cleanup_registered = true; - } - ScrDyn *prototype = scr_dyn_new_obj(); - scr_js_wrapper_define_hidden(prototype, "constructor", scr_dyn_retain(constructor)); - const ScrJsWrapperMember *members = kind == SCR_JS_WRAPPER_STRING ? scr_js_wrapper_string_members - : kind == SCR_JS_WRAPPER_NUMBER ? scr_js_wrapper_number_members - : scr_js_wrapper_boolean_members; - for (const ScrJsWrapperMember *m = members; m->name; m++) - scr_js_wrapper_define_hidden(prototype, m->name, scr_js_wrapper_method_value(m->name, kind, m->length)); - scr_js_wrapper_prototypes[kind] = prototype; - return prototype; -} - -/* The wrapper of kind `kind` over `primitive` (borrowed; already the - * converted primitive). +1. */ -static ScrDyn *scr_js_wrapper_make(int kind, ScrDyn *primitive, ScrDyn *const constructors[3]) { +/* An object with the [[PrimitiveValue]] slot of kind `kind` over + * `primitive` (borrowed; already the converted primitive) and, for a + * String, its index properties and `length`. Its prototype is still + * Object.prototype. +1. */ +static ScrDyn *scr_js_wrapper_object(int kind, ScrDyn *primitive) { ScrJsWrapperSlot *slot = malloc(sizeof *slot); if (!slot) scr_trap("scriptc: out of memory\n"); *slot = (ScrJsWrapperSlot){ 0, kind, scr_dyn_retain(primitive), NULL }; /* scr_dyn_new_obj_with_identity retains once: the object's reference. */ ScrDyn *object = scr_dyn_new_obj_with_identity(slot, scr_js_wrapper_slot_retain, scr_js_wrapper_slot_access); slot->object = object; - object->prototype = scr_dyn_retain(scr_js_wrapper_prototype(kind, constructors[kind])); if (kind == SCR_JS_WRAPPER_STRING) { /* Each UTF-16 code unit is an own index property. */ ScrStr *text = primitive->v.str; @@ -312,6 +293,45 @@ static ScrDyn *scr_js_wrapper_make(int kind, ScrDyn *primitive, ScrDyn *const co return object; } +/* The kind's prototype object (borrowed), built on first use with + * `constructor` (the compiled constructor value) and the members above. + * As in Node, it is itself a wrapper of "", 0 or false, so its valueOf + * and toString answer that primitive; its own prototype is + * Object.prototype. */ +static ScrDyn *scr_js_wrapper_prototype(int kind, ScrDyn *constructor) { + if (scr_js_wrapper_prototypes[kind]) return scr_js_wrapper_prototypes[kind]; + if (!scr_js_wrapper_cleanup_registered) { + scr_atexit(scr_js_wrapper_cleanup); + scr_js_wrapper_cleanup_registered = true; + } + ScrDyn *primitive; + if (kind == SCR_JS_WRAPPER_STRING) { + ScrStr *empty = scr_str_new("", 0); + primitive = scr_dyn_new_str(empty); + scr_str_release(empty); + } else { + primitive = kind == SCR_JS_WRAPPER_NUMBER ? scr_dyn_new_num(0) : scr_dyn_new_bool(false); + } + ScrDyn *prototype = scr_js_wrapper_object(kind, primitive); + scr_dyn_release(primitive); + scr_js_wrapper_define_hidden(prototype, "constructor", scr_dyn_retain(constructor)); + const ScrJsWrapperMember *members = kind == SCR_JS_WRAPPER_STRING ? scr_js_wrapper_string_members + : kind == SCR_JS_WRAPPER_NUMBER ? scr_js_wrapper_number_members + : scr_js_wrapper_boolean_members; + for (const ScrJsWrapperMember *m = members; m->name; m++) + scr_js_wrapper_define_hidden(prototype, m->name, scr_js_wrapper_method_value(m->name, kind, m->length)); + scr_js_wrapper_prototypes[kind] = prototype; + return prototype; +} + +/* The wrapper of kind `kind` over `primitive` (borrowed; already the + * converted primitive). +1. */ +static ScrDyn *scr_js_wrapper_make(int kind, ScrDyn *primitive, ScrDyn *const constructors[3]) { + ScrDyn *object = scr_js_wrapper_object(kind, primitive); + object->prototype = scr_dyn_retain(scr_js_wrapper_prototype(kind, constructors[kind])); + return object; +} + /* `new String(v)` / `new Number(v)` / `new Boolean(v)`: kind 0, 1 or 2; * `value` is the argument (the undefined value is converted like any * other: callers pass "", 0 or false for an omitted argument). The @@ -411,29 +431,88 @@ bool scr_js_wrapper_type_is(ScrDyn *value, ScrStr *probe) { return false; } +/* The objects and arrays of a guarded value's graph still to search (a + * stack), and the ones already reached (an open-addressing set of + * addresses), each in inline storage until it outgrows it. */ +typedef struct { + const ScrDyn **stack; + size_t depth, stack_cap; + const ScrDyn **seen; + size_t seen_count, seen_cap; /* seen_cap is a power of two */ + const ScrDyn *stack_inline[32]; + const ScrDyn *seen_inline[64]; +} ScrJsWrapperWalk; + +static size_t scr_js_wrapper_seen_slot(const ScrDyn *const *seen, size_t cap, const ScrDyn *value) { + size_t i = ((uintptr_t)value >> 4) * 0x9E3779B97F4A7C15ull & (cap - 1); + while (seen[i] && seen[i] != value) i = (i + 1) & (cap - 1); + return i; +} + +/* Adds an object or array to the search unless it was already reached. */ +static void scr_js_wrapper_walk_reach(ScrJsWrapperWalk *walk, const ScrDyn *value) { + if (!value || (value->kind != SCR_DYN_OBJ && value->kind != SCR_DYN_ARR)) return; + size_t i = scr_js_wrapper_seen_slot(walk->seen, walk->seen_cap, value); + if (walk->seen[i]) return; + walk->seen[i] = value; + if (++walk->seen_count * 2 > walk->seen_cap) { + size_t cap = walk->seen_cap * 2; + const ScrDyn **grown = calloc(cap, sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + for (size_t j = 0; j < walk->seen_cap; j++) + if (walk->seen[j]) grown[scr_js_wrapper_seen_slot(grown, cap, walk->seen[j])] = walk->seen[j]; + if (walk->seen != walk->seen_inline) free(walk->seen); + walk->seen = grown; + walk->seen_cap = cap; + } + if (walk->depth == walk->stack_cap) { + size_t cap = walk->stack_cap * 2; + const ScrDyn **grown = malloc(cap * sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + memcpy(grown, walk->stack, walk->depth * sizeof *grown); + if (walk->stack != walk->stack_inline) free(walk->stack); + walk->stack = grown; + walk->stack_cap = cap; + } + walk->stack[walk->depth++] = value; +} + /* Whether `value`'s own data graph (array elements, own data properties of * ordinary objects and arrays; accessors are not called) holds a wrapper. - * `depth` bounds cycles: a graph deeper than the limit is not searched - * further. */ -static bool scr_js_wrapper_found(const ScrDyn *value, int depth) { - if (depth > 64) return false; - if (value->kind == SCR_DYN_OBJ) { - ScrJsWrapperSlot *slot = scr_js_wrapper_slot_of(value); - if (slot) { - scr_js_wrapper_slot_drop(slot); - return true; + * Each object and array is searched once, so shared and cyclic references + * end the search. */ +static bool scr_js_wrapper_found(const ScrDyn *value) { + if (value->kind != SCR_DYN_OBJ && value->kind != SCR_DYN_ARR) return false; + ScrJsWrapperWalk walk; + walk.stack = walk.stack_inline; + walk.depth = 0; + walk.stack_cap = sizeof walk.stack_inline / sizeof *walk.stack_inline; + walk.seen = walk.seen_inline; + walk.seen_count = 0; + walk.seen_cap = sizeof walk.seen_inline / sizeof *walk.seen_inline; + memset(walk.seen_inline, 0, sizeof walk.seen_inline); + scr_js_wrapper_walk_reach(&walk, value); + bool found = false; + while (!found && walk.depth > 0) { + const ScrDyn *current = walk.stack[--walk.depth]; + if (current->kind == SCR_DYN_OBJ) { + ScrJsWrapperSlot *slot = scr_js_wrapper_slot_of(current); + if (slot) { + scr_js_wrapper_slot_drop(slot); + found = true; + break; + } + for (size_t i = 0; i < scr_dyn_obj_count(current); i++) { + ScrDynProp prop = scr_dyn_obj_prop_at(current, i); + if (!prop.accessor) scr_js_wrapper_walk_reach(&walk, prop.value); + } + } else { + for (size_t i = 0; i < current->v.arr.len; i++) scr_js_wrapper_walk_reach(&walk, current->v.arr.items[i]); } - for (size_t i = 0; i < scr_dyn_obj_count(value); i++) { - ScrDynProp prop = scr_dyn_obj_prop_at(value, i); - if (!prop.accessor && prop.value && scr_js_wrapper_found(prop.value, depth + 1)) return true; - } - return false; } - if (value->kind == SCR_DYN_ARR) { - for (size_t i = 0; i < value->v.arr.len; i++) - if (value->v.arr.items[i] && scr_js_wrapper_found(value->v.arr.items[i], depth + 1)) return true; - } - return false; + if (walk.stack != walk.stack_inline) free(walk.stack); + if (walk.seen != walk.seen_inline) free(walk.seen); + return found; } static void scr_js_wrapper_refuse(const ScrStr *consumer) { @@ -450,7 +529,7 @@ static void scr_js_wrapper_refuse(const ScrStr *consumer) { * graph holds a wrapper: the consumer named by `consumer` cannot read the * [[PrimitiveValue]] slot yet. */ ScrDyn *scr_js_wrapper_guard(ScrDyn *value, ScrStr *consumer) { - if (scr_js_wrapper_found(value, 0)) { + if (scr_js_wrapper_found(value)) { scr_js_wrapper_refuse(consumer); return NULL; } @@ -461,7 +540,7 @@ ScrDyn *scr_js_wrapper_guard(ScrDyn *value, ScrStr *consumer) { ScrArr *scr_js_wrapper_guard_list(ScrArr *values, ScrStr *consumer) { for (size_t i = 0; i < values->len; i++) { ScrDyn *value = scr_arr_get_ref(values, (double)i); /* +1 */ - bool found = scr_js_wrapper_found(value, 0); + bool found = scr_js_wrapper_found(value); scr_dyn_release(value); if (found) { scr_js_wrapper_refuse(consumer); @@ -471,25 +550,18 @@ ScrArr *scr_js_wrapper_guard_list(ScrArr *values, ScrStr *consumer) { return scr_arr_retain(values); } -/* Whether `name` on `value` resolves to this unit's String.prototype - * method of that name: the compiler then runs its own lowering of the - * method on ToString of the receiver (scr_js_wrapper_this_string), which - * is what the builtin does. The read runs any getter on the chain, as the - * member read of a call does. */ -bool scr_js_wrapper_is_string_method(ScrDyn *value, ScrStr *name) { - if (value->kind != SCR_DYN_OBJ) return false; - ScrDyn *member = scr_dyn_obj_read(value, name->data, name->len); - if (!member) return false; - bool builtin = false; - if (member->kind == SCR_DYN_FUNC && member->v.fn.thunk == scr_js_wrapper_method) { - ScrDyn *member_name = scr_box_get_ref(member->v.fn.clo->caps[0]); - ScrDyn *kind = scr_box_get_ref(member->v.fn.clo->caps[1]); - builtin = (int)kind->v.num == SCR_JS_WRAPPER_STRING && member_name->v.str->len == name->len && - memcmp(member_name->v.str->data, name->data, name->len) == 0; - scr_dyn_release(kind); - scr_dyn_release(member_name); - } - scr_dyn_release(member); +/* Whether `member`, the value a call read as the callee's member `name`, + * is this unit's String.prototype method of that name: the compiler then + * runs its own lowering of the method on ToString of the receiver + * (scr_js_wrapper_this_string), which is what the builtin does. */ +bool scr_js_wrapper_is_string_method(ScrDyn *member, ScrStr *name) { + if (member->kind != SCR_DYN_FUNC || member->v.fn.thunk != scr_js_wrapper_method) return false; + ScrDyn *member_name = scr_box_get_ref(member->v.fn.clo->caps[0]); + ScrDyn *kind = scr_box_get_ref(member->v.fn.clo->caps[1]); + bool builtin = (int)kind->v.num == SCR_JS_WRAPPER_STRING && member_name->v.str->len == name->len && + memcmp(member_name->v.str->data, name->data, name->len) == 0; + scr_dyn_release(kind); + scr_dyn_release(member_name); return builtin; } diff --git a/tests/corpus/js-primitive-wrappers.js b/tests/corpus/js-primitive-wrappers.js index 35050eadb..bfcb06c7e 100644 --- a/tests/corpus/js-primitive-wrappers.js +++ b/tests/corpus/js-primitive-wrappers.js @@ -57,3 +57,29 @@ try { } catch (error) { console.log("brand", error.constructor.name, error.message); } + +// The prototypes are themselves wrappers of "", 0 and false, as in Node. +const stringPrototype = Object.getPrototypeOf(s); +const numberPrototype = Object.getPrototypeOf(n); +const booleanPrototype = Object.getPrototypeOf(new Boolean(true)); +console.log("prototype values", stringPrototype.valueOf() === "", numberPrototype.valueOf(), booleanPrototype.valueOf()); +console.log("prototype strings", stringPrototype.length, `${numberPrototype}`, String(booleanPrototype), numberPrototype + 1); +console.log("prototype tags", Object.prototype.toString.call(stringPrototype), Object.prototype.toString.call(numberPrototype)); +console.log("prototype instances", stringPrototype instanceof String, Object.getOwnPropertyNames(stringPrototype).slice(0, 2).join()); + +// A string-named method call on a dynamic object reads the member once. +let trimReads = 0; +const trimmed = { + get trim() { + trimReads++; + return trimReads === 1 ? () => "first read" : () => "second read"; + }, +}; +const callTrim = (value) => value.trim(); +console.log("member reads", callTrim(trimmed), trimReads, callTrim(" text ")); + +// A graph that shares its objects, in a program with wrappers: the wrapper +// check before console.log searches each object once. +let sharedGraph = { leaf: 1 }; +for (let i = 0; i < 40; i++) sharedGraph = { left: sharedGraph, right: sharedGraph }; +console.log(sharedGraph); diff --git a/tests/harness/primitive-wrapper-refusals.test.ts b/tests/harness/primitive-wrapper-refusals.test.ts index c6022661c..25323864b 100644 --- a/tests/harness/primitive-wrapper-refusals.test.ts +++ b/tests/harness/primitive-wrapper-refusals.test.ts @@ -11,13 +11,17 @@ const execFileAsync = promisify(execFile); // JSON, util.inspect, structuredClone and deep equality cannot read a // wrapper's [[PrimitiveValue]] slot yet, so a wrapper anywhere in their // argument refuses with a catchable error instead of a silently wrong -// answer. Values without a wrapper keep working in the same program. +// answer, including one 100 levels deep or under a graph that shares its +// objects. Values without a wrapper keep working in the same program, and +// the search reaches each object once, so a cyclic value is checked in +// linear time. test("graph-walking consumers refuse primitive wrapper objects", async () => { const outDir = mkdtempSync(join(tmpdir(), "scriptc-wrapper-refusals-")); const entry = join(outDir, "main.mjs"); writeFileSync( entry, ` +import util from "node:util"; function attempt(label, run) { try { run(); process.stdout.write(label + " ok\\n"); } catch (error) { process.stdout.write(label + " " + error.message + "\\n"); } } @@ -26,6 +30,16 @@ attempt("log", () => console.log([new String("a")])); attempt("clone", () => structuredClone([new Boolean(true)])); attempt("plain json", () => JSON.stringify({ v: 3 })); attempt("plain clone", () => structuredClone([true])); +let deep = new String("deep"); +for (let i = 0; i < 100; i++) deep = { next: deep }; +attempt("deep clone", () => structuredClone(deep)); +let shared = [new Number(1)]; +for (let i = 0; i < 40; i++) shared = { left: shared, right: shared }; +attempt("shared log", () => console.log(shared)); +const cyclic = JSON.parse('{ "name": "cyclic" }'); +cyclic.self = cyclic; +cyclic.again = cyclic; +attempt("cyclic inspect", () => util.inspect(cyclic)); `, ); const result = await compile(entry, { @@ -46,6 +60,9 @@ attempt("plain clone", () => structuredClone([true])); `clone ${refused("structuredClone")}`, "plain json ok", "plain clone ok", + `deep clone ${refused("structuredClone")}`, + `shared log ${refused("console.log/util.inspect")}`, + "cyclic inspect ok", "", ].join("\n"), ); From f9b024dbab8d96c531505609a3458618994f597e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 13:36:41 -0500 Subject: [PATCH 149/193] Count ArrayBuffer storage in process.memoryUsage() process.memoryUsage() reported external and arrayBuffers as 0. arrayBuffers is now the storage of the ArrayBuffers, typed arrays, Buffers, DataViews and SharedArrayBuffers the program holds, and external the same storage without SharedArrayBuffers, as Node splits them. A view over existing storage adds nothing. v8.getHeapStatistics().external_memory reads the same figure as external. Each ScrBytes owner records the bytes it counts in the struct's padding, so ScrBytes stays 64 bytes and the offsets compiled code reads hold; a static assertion pins them. Owners are counted in a per-instance counter, because their reference counts are not atomic and they are released on their instance's thread. Shared storage moves its owner's count to a process-wide atomic counter, since any thread may release it. --- .../builtin-apis/process-memory.mdx | 5 +- docs/content/limitations/builtin-apis/v8.mdx | 4 +- .../src/frontend/lowering/builtins/v8.ts | 12 +++-- ...s-memory-usage-array-buffers-typed.ts.json | 6 +++ ...rocess-memory-usage-array-buffers.cjs.json | 6 +++ packages/runtime/src/scr_bytes.c | 53 +++++++++++++++---- packages/runtime/src/scr_bytes.h | 43 +++++++++++++++ packages/runtime/src/scr_file_handle.c | 2 +- packages/runtime/src/scr_lib.c | 10 ++-- packages/runtime/src/scr_shared.c | 5 ++ ...rocess-memory-usage-array-buffers-typed.ts | 29 ++++++++++ .../process-memory-usage-array-buffers.cjs | 41 ++++++++++++++ tests/corpus/v8-heap-statistics.ts | 7 +++ ...s-memory-usage-array-buffers-typed.ts.json | 1 + ...rocess-memory-usage-array-buffers.cjs.json | 1 + 15 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers-typed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers.cjs.json create mode 100644 tests/corpus/process-memory-usage-array-buffers-typed.ts create mode 100644 tests/corpus/process-memory-usage-array-buffers.cjs create mode 100644 tests/harness/corpus-costs/process-memory-usage-array-buffers-typed.ts.json create mode 100644 tests/harness/corpus-costs/process-memory-usage-array-buffers.cjs.json diff --git a/docs/content/limitations/builtin-apis/process-memory.mdx b/docs/content/limitations/builtin-apis/process-memory.mdx index 6a3121e2f..6865fd7ff 100644 --- a/docs/content/limitations/builtin-apis/process-memory.mdx +++ b/docs/content/limitations/builtin-apis/process-memory.mdx @@ -1,2 +1,5 @@ - **Process memory:** `process.memoryUsage()` reads `rss` from the operating system and `heapTotal` and `heapUsed` from the runtime's allocator, so the heap figures start near zero and grow as the program allocates. - `external` and `arrayBuffers` are `0`, because Buffer and typed-array storage is part of the same heap. + `arrayBuffers` is the storage of the ArrayBuffers, typed arrays, Buffers, DataViews and SharedArrayBuffers the program holds, and `external` is the same storage without SharedArrayBuffers, as in Node. + Node also counts its own startup allocations in both, so a program that holds no such storage reports `0` where Node reports a few megabytes. + In a program with worker threads, `external` and the non-shared part of `arrayBuffers` count the calling thread's storage. + The storage comes from the same allocator, so it also counts in `heapTotal` and `heapUsed`. diff --git a/docs/content/limitations/builtin-apis/v8.mdx b/docs/content/limitations/builtin-apis/v8.mdx index 066fb000d..daafa3d6c 100644 --- a/docs/content/limitations/builtin-apis/v8.mdx +++ b/docs/content/limitations/builtin-apis/v8.mdx @@ -1,5 +1,5 @@ - **V8 heap statistics:** `node:v8` can be imported. - `v8.getHeapStatistics()` returns Node's fields in Node's order, computed from the runtime allocator like `process.memoryUsage()`: `total_heap_size` and `total_physical_size` are the bytes the heap holds, `used_heap_size` the bytes allocated, `heap_size_limit` the machine's memory (the heap has no fixed ceiling), and `total_available_size` the limit minus the used bytes. - Fields that describe V8 itself (`total_heap_size_executable`, `malloced_memory`, `peak_malloced_memory`, the global handle sizes, `external_memory`, and `does_zap_garbage`) are `0`, and `number_of_native_contexts` is `1`. + `v8.getHeapStatistics()` returns Node's fields in Node's order, computed from the runtime allocator like `process.memoryUsage()`: `total_heap_size` and `total_physical_size` are the bytes the heap holds, `used_heap_size` the bytes allocated, `external_memory` the same figure as `process.memoryUsage().external`, `heap_size_limit` the machine's memory (the heap has no fixed ceiling), and `total_available_size` the limit minus the used bytes. + Fields that describe V8 itself (`total_heap_size_executable`, `malloced_memory`, `peak_malloced_memory`, the global handle sizes, and `does_zap_garbage`) are `0`, and `number_of_native_contexts` is `1`. The values are approximate and are not V8 heap figures. The other `node:v8` members observe the V8 engine, such as heap snapshots, profiles, flags, and serialization, and throw `SC2020` where they are used. diff --git a/packages/compiler/src/frontend/lowering/builtins/v8.ts b/packages/compiler/src/frontend/lowering/builtins/v8.ts index 989a61a3f..f95a6e6c2 100644 --- a/packages/compiler/src/frontend/lowering/builtins/v8.ts +++ b/packages/compiler/src/frontend/lowering/builtins/v8.ts @@ -30,11 +30,11 @@ const HEAP_STATISTICS_FIELDS = [ ]; /** `v8.getHeapStatistics()`: one memoryUsage sample (the runtime heap's - * total and used bytes), with the machine's memory as the heap limit (the - * runtime heap has no fixed ceiling) and the available size the limit - * minus used bytes. V8-only figures (executable code space, malloced - * memory, global handles, external memory, zapping) read 0, and one - * native context exists. The record is the call site's interned HeapInfo + * total and used bytes, and external memory, memoryUsage's `external`), + * with the machine's memory as the heap limit (the runtime heap has no + * fixed ceiling) and the available size the limit minus used bytes. + * V8-only figures (executable code space, malloced memory, global handles, + * zapping) read 0, and one native context exists. The record is the call site's interned HeapInfo * shape, built by a shared helper per shape. */ export function lowerV8HeapStatisticsCall( lowerer: Lowerer, @@ -73,6 +73,8 @@ export function lowerV8HeapStatisticsCall( return sample(1); case "used_heap_size": return sample(2); + case "external_memory": + return sample(3); case "heap_size_limit": return limit(); case "total_available_size": diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers-typed.ts.json new file mode 100644 index 000000000..0a1424dad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-memory-usage-array-buffers-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers.cjs.json new file mode 100644 index 000000000..93d0a37fa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-memory-usage-array-buffers.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-memory-usage-array-buffers.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 6bb56195a..03cb552b4 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -173,15 +173,37 @@ double scr_bytes_to_u8_clamp(double value) { /* ── lifecycle ─────────────────────────────────────────────────────────── */ -/* Adopt private malloc storage without clearing or copying its contents. The - * caller transfers ownership; views keep it alive through the usual owner. */ -ScrBytes *scr_bytes_take_data(uint8_t *data, size_t len) { +/* The storage of this instance's owners, which are created and released on + * its thread (their reference counts are not atomic), and of the + * SharedArrayBuffer storage of the process, which any thread may release. */ +static SCR_TL size_t scr_array_buffers_local; +static size_t scr_array_buffers_shared; + +double scr_array_buffers_held(bool shared) { + return shared ? (double)__atomic_load_n(&scr_array_buffers_shared, __ATOMIC_RELAXED) + : (double)scr_array_buffers_local; +} + +void scr_array_buffers_sub(bool shared, size_t n) { + if (shared) __atomic_fetch_sub(&scr_array_buffers_shared, n, __ATOMIC_RELAXED); + else scr_array_buffers_local -= n; +} + +void scr_array_buffers_share(size_t n) { + scr_array_buffers_local -= n; + __atomic_fetch_add(&scr_array_buffers_shared, n, __ATOMIC_RELAXED); +} + +/* An owner of `len` elements of `elem` over malloc storage `data`, counting + * `held` bytes of it in scr_array_buffers_held. */ +__attribute__((always_inline)) static inline ScrBytes * +scr_bytes_own(uint8_t *data, size_t len, ScrBytesElem elem, size_t held) { if (!data && len) scr_trap("scriptc: NULL owned byte storage\n"); ScrBytes *b = malloc(sizeof(ScrBytes)); if (!b) scr_bytes_oom(); b->rc = 1; b->len = len; - b->elem = SCR_BYTES_U8; + b->elem = elem; b->data = data ? data : malloc(1); if (!b->data) scr_bytes_oom(); b->backing = NULL; @@ -192,21 +214,27 @@ ScrBytes *scr_bytes_take_data(uint8_t *data, size_t len) { b->shared = NULL; b->properties = NULL; b->properties_traced = false; + scr_bytes_set_held(b, held); + scr_array_buffers_local += held; #ifdef SCR_RC_AUDIT scr_live_bytes++; #endif return b; } +/* Adopt private malloc storage without clearing or copying its contents. The + * caller transfers ownership; views keep it alive through the usual owner. */ +ScrBytes *scr_bytes_take_data(uint8_t *data, size_t len) { + return scr_bytes_own(data, len, SCR_BYTES_U8, len); +} + /* Only complete, non-observable initialization may use private storage. */ static ScrBytes *scr_bytes_alloc_private(ScrBytesElem elem, size_t len) { size_t width = scr_bytes_elem_size(elem); if (len > SIZE_MAX / width) scr_bytes_oom(); uint8_t *data = malloc(len ? len * width : 1); if (!data) scr_bytes_oom(); - ScrBytes *b = scr_bytes_take_data(data, len); - b->elem = elem; - return b; + return scr_bytes_own(data, len, elem, len * width); } static ScrBytes *scr_bytes_alloc(ScrBytesElem elem, size_t len) { @@ -214,9 +242,7 @@ static ScrBytes *scr_bytes_alloc(ScrBytesElem elem, size_t len) { if (len > SIZE_MAX / width) scr_bytes_oom(); uint8_t *data = calloc(len ? len : 1, width); if (!data) scr_bytes_oom(); - ScrBytes *b = scr_bytes_take_data(data, len); - b->elem = elem; - return b; + return scr_bytes_own(data, len, elem, len * width); } ScrBytes *scr_bytes_from_external(void *data, size_t length) { @@ -229,6 +255,7 @@ ScrBytes *scr_bytes_from_external(void *data, size_t length) { b->external = true; } b->len = length; + scr_bytes_set_held(b, 0); return b; } @@ -312,6 +339,7 @@ void scr_bytes_release(ScrBytes *b) { } else if (b->shared) { scr_shared_release(b->shared); } else if (!b->external) { + scr_array_buffers_local -= scr_bytes_held(b); free(b->data); } #ifdef SCR_RC_AUDIT @@ -745,6 +773,7 @@ ScrBytes *scr_bytes_subarray(ScrBytes *b, double start, double end) { v->shared = owner->shared; v->properties = NULL; v->properties_traced = false; + scr_bytes_set_held(v, 0); #ifdef SCR_RC_AUDIT scr_live_bytes++; #endif @@ -1018,6 +1047,7 @@ ScrBytes *scr_dataview_new(ScrBytes *src, double byte_off, bool has_len, double v->shared = owner->shared; v->properties = NULL; v->properties_traced = false; + scr_bytes_set_held(v, 0); #ifdef SCR_RC_AUDIT scr_live_bytes++; #endif @@ -2819,7 +2849,7 @@ static ScrBytes *scr_bytes_conversion_storage_in(ScrBytesSink *sink, size_t capa } uint8_t *data = malloc(capacity ? capacity : 1); if (!data) scr_bytes_oom(); - return scr_bytes_take_data(data, 0); + return scr_bytes_own(data, 0, SCR_BYTES_U8, capacity); } static ScrBytes *scr_bytes_conversion_storage(size_t capacity) { @@ -3169,6 +3199,7 @@ static ScrBytes *scr_pool_take(size_t len) { v->shared = NULL; v->properties = NULL; v->properties_traced = false; + scr_bytes_set_held(v, 0); #ifdef SCR_RC_AUDIT scr_live_bytes++; #endif diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4b9174bc0..e3846737d 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -55,6 +55,11 @@ typedef struct ScrBytes { size_t rc; size_t len; /* ELEMENT count, fixed at construction */ ScrBytesElem elem; +#if SIZE_MAX > UINT32_MAX + /* Bits 0-31 of the storage an owner counts (scr_bytes_held), in the + * padding after elem, so the offsets compiled code uses hold. */ + uint32_t held_lo; +#endif uint8_t *data; /* len * elem_size bytes; owned unless backing is set */ /* NULL for owners. A view (DataView, subarray, Buffer-slice) sets this * to the retained OWNER it aliases (chain depth is always exactly 1: @@ -69,12 +74,41 @@ typedef struct ScrBytes { * value that held this view (see scr_dyn_trace_v). In padding, so the * offsets compiled code uses hold. */ bool properties_traced; +#if SIZE_MAX > UINT32_MAX + uint16_t held_hi; /* bits 32-47 of the counted storage, in padding */ +#endif ScrSharedBytes *shared; /* Neutral backing, borrowed by views from their local owner. */ /* Named properties a program adds to the view (`ta.foo = 1`, * Object.defineProperty): a checked object, NULL until the first one. * Runtime-only and last, so compiled code's field indices hold. */ struct ScrDyn *properties; +#if SIZE_MAX <= UINT32_MAX + uint32_t held_lo; /* the counted storage, runtime-only */ +#endif } ScrBytes; +#if SIZE_MAX > UINT32_MAX +_Static_assert(offsetof(ScrBytes, data) == 24 && offsetof(ScrBytes, shared) == 48 && sizeof(ScrBytes) == 64, + "the counted storage sits in padding: compiled code reads data at 24 and shared at 48"); +#endif + +/* The bytes of storage an owner counts in process.memoryUsage()'s + * arrayBuffers and external (scr_array_buffers_held): 0 for views, foreign + * memory and owners whose storage became shared. On 64-bit targets its 48 + * bits hold any size the address space can allocate. */ +static inline size_t scr_bytes_held(const ScrBytes *b) { +#if SIZE_MAX > UINT32_MAX + return (size_t)b->held_lo | (size_t)b->held_hi << 32; +#else + return b->held_lo; +#endif +} + +static inline void scr_bytes_set_held(ScrBytes *b, size_t n) { + b->held_lo = (uint32_t)n; +#if SIZE_MAX > UINT32_MAX + b->held_hi = (uint16_t)(n >> 32); +#endif +} /* Copies out of and into shared storage, made of relaxed atomic accesses * (aligned 8-byte words, single bytes at unaligned edges). Lock-free @@ -204,6 +238,15 @@ ScrBytes *scr_array_buffer_view_dv(ScrDyn *, ScrDyn *, ScrDyn *); * empty span. */ ScrBytes *scr_bytes_from_data(const uint8_t *data, size_t len); /* +1 */ ScrBytes *scr_bytes_take_data(uint8_t *data, size_t len); /* +1, consumes malloc storage */ +/* Bytes of storage the runtime holds: the typed array, Buffer and DataView + * owners of the calling instance (shared false), and the SharedArrayBuffer + * storage of the process (shared true). process.memoryUsage()'s + * arrayBuffers is their sum and external counts the first, as Node's do. + * scr_array_buffers_sub uncounts released storage; scr_array_buffers_share + * moves an owner's bytes to shared storage. */ +double scr_array_buffers_held(bool shared); +void scr_array_buffers_sub(bool shared, size_t n); +void scr_array_buffers_share(size_t n); /* Same-element-kind copy, preserving the raw representation. Cross-kind * construction uses scr_bytes_convert. Borrows src. Never throws. */ diff --git a/packages/runtime/src/scr_file_handle.c b/packages/runtime/src/scr_file_handle.c index ed0580165..d5a3bb7ca 100644 --- a/packages/runtime/src/scr_file_handle.c +++ b/packages/runtime/src/scr_file_handle.c @@ -422,7 +422,7 @@ ScrBytes *scr_file_handle_read_file_bytes(ScrFileHandle *h) { static void scr_file_handle_write_all(ScrFileHandle *h, const void *data, size_t length) { if (!scr_file_handle_require_open(h)) return; - ScrBytes bytes = {SIZE_MAX, length, SCR_BYTES_U8, (uint8_t *)data, NULL}; + ScrBytes bytes = {.rc = SIZE_MAX, .len = length, .elem = SCR_BYTES_U8, .data = (uint8_t *)data}; size_t at = 0; while (at < length) { double count = scr_fs_write_sync((double)h->fd, &bytes, (double)at, diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9ef71a223..4ed421f7a 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -2659,8 +2659,12 @@ void scr_process_memory_usage_take(void) { scr_memory_usage_sample[0] = rss; scr_memory_usage_sample[1] = total; scr_memory_usage_sample[2] = used; - scr_memory_usage_sample[3] = 0; - scr_memory_usage_sample[4] = 0; + /* external is the storage of the ArrayBuffers and typed arrays this + * instance holds, and arrayBuffers adds the SharedArrayBuffer storage of + * the process, as Node counts them. */ + double local = scr_array_buffers_held(false); + scr_memory_usage_sample[3] = local; + scr_memory_usage_sample[4] = local + scr_array_buffers_held(true); } double scr_process_memory_usage_field(double k) { @@ -4447,7 +4451,7 @@ static double scr_fs_vector_sync(double fd, ScrArr *buffers, double position, scr_bytes_release(buf); } } - ScrBytes window = {SIZE_MAX, length, SCR_BYTES_U8, data, NULL}; + ScrBytes window = {.rc = SIZE_MAX, .len = length, .elem = SCR_BYTES_U8, .data = data}; position = isfinite(position) && position >= 0 && position <= 9007199254740991.0 ? trunc(position) : -1; double transferred = writing diff --git a/packages/runtime/src/scr_shared.c b/packages/runtime/src/scr_shared.c index 6505a71ab..0cff859d3 100644 --- a/packages/runtime/src/scr_shared.c +++ b/packages/runtime/src/scr_shared.c @@ -24,6 +24,7 @@ struct ScrSharedBytes { atomic_size_t references; size_t length; uint8_t *data; + size_t held; /* the owner's count, moved to the shared bytes (scr_array_buffers_share) */ }; void scr_shared_load(void *dst, const void *src, size_t n) { @@ -56,6 +57,7 @@ ScrSharedBytes *scr_shared_retain(ScrSharedBytes *storage) { void scr_shared_release(ScrSharedBytes *storage) { if (!storage || atomic_fetch_sub_explicit(&storage->references, 1, memory_order_acq_rel) != 1) return; + scr_array_buffers_sub(true, storage->held); free(storage->data); free(storage); } @@ -67,6 +69,9 @@ void scr_bytes_make_shared(ScrBytes *bytes) { atomic_init(&storage->references, 1); storage->length = bytes->len * scr_bytes_elem_size(bytes->elem); storage->data = bytes->data; + storage->held = scr_bytes_held(bytes); + scr_array_buffers_share(storage->held); + scr_bytes_set_held(bytes, 0); bytes->shared = storage; } diff --git a/tests/corpus/process-memory-usage-array-buffers-typed.ts b/tests/corpus/process-memory-usage-array-buffers-typed.ts new file mode 100644 index 000000000..6ccac3e43 --- /dev/null +++ b/tests/corpus/process-memory-usage-array-buffers-typed.ts @@ -0,0 +1,29 @@ +// process.memoryUsage() in typed code counts the storage of typed arrays +// and Buffers created there, and a subarray view adds nothing. +const size = 2 * 1024 * 1024; +function grew(before: NodeJS.MemoryUsage, after: NodeJS.MemoryUsage): string { + return `${after.arrayBuffers - before.arrayBuffers >= size} ${after.external - before.external >= size}`; +} + +let before = process.memoryUsage(); +const bytes = new Uint8Array(size); +console.log("Uint8Array", bytes.length, grew(before, process.memoryUsage())); + +before = process.memoryUsage(); +const ints = new Int32Array(size / 4); +ints[0] = 7; +console.log("Int32Array", ints.length, ints[0], grew(before, process.memoryUsage())); + +before = process.memoryUsage(); +const buf = Buffer.alloc(size); +buf[1] = 3; +console.log("Buffer.alloc", buf.length, grew(before, process.memoryUsage())); + +before = process.memoryUsage(); +const copy = Uint8Array.from(bytes); +console.log("Uint8Array.from", copy.length, grew(before, process.memoryUsage())); + +before = process.memoryUsage(); +const view = bytes.subarray(16); +const after = process.memoryUsage(); +console.log("subarray", view.length, after.arrayBuffers - before.arrayBuffers < size); diff --git a/tests/corpus/process-memory-usage-array-buffers.cjs b/tests/corpus/process-memory-usage-array-buffers.cjs new file mode 100644 index 000000000..2c96b7f6b --- /dev/null +++ b/tests/corpus/process-memory-usage-array-buffers.cjs @@ -0,0 +1,41 @@ +// process.memoryUsage() counts the storage of ArrayBuffers, typed arrays, +// Buffers, DataViews and SharedArrayBuffers in arrayBuffers, and external +// includes arrayBuffers. A view over existing storage adds nothing. +const size = 4 * 1024 * 1024; +const grew = (before, after) => + [after.arrayBuffers - before.arrayBuffers >= size, after.external - before.external >= size]; +const sample = () => process.memoryUsage(); + +let before = sample(); +console.log(typeof before.arrayBuffers, typeof before.external, before.external >= before.arrayBuffers); +const ab = new ArrayBuffer(size); +console.log("ArrayBuffer", ab.byteLength, ...grew(before, sample())); + +before = sample(); +const f64 = new Float64Array(size / 8); +console.log("Float64Array", f64.byteLength, ...grew(before, sample())); + +before = sample(); +const buf = Buffer.alloc(size, 1); +console.log("Buffer.alloc", buf.length, ...grew(before, sample())); + +before = sample(); +const unsafe = Buffer.allocUnsafeSlow(size); +console.log("Buffer.allocUnsafeSlow", unsafe.length, ...grew(before, sample())); + +before = sample(); +const text = Buffer.from("x".repeat(size)); +console.log("Buffer.from(string)", text.length, ...grew(before, sample())); + +before = sample(); +const sab = new SharedArrayBuffer(size); +const shared = new Int32Array(sab); +console.log("SharedArrayBuffer", sab.byteLength, shared.length, ...grew(before, sample())); + +before = sample(); +const views = [new DataView(ab), buf.subarray(1), new Uint8Array(f64.buffer, 8, 16)]; +const after = sample(); +console.log("views", views.length, after.arrayBuffers - before.arrayBuffers < size); + +const all = sample(); +console.log(all.arrayBuffers >= 6 * size, all.external >= all.arrayBuffers); diff --git a/tests/corpus/v8-heap-statistics.ts b/tests/corpus/v8-heap-statistics.ts index 9586f60a2..64109d7a3 100644 --- a/tests/corpus/v8-heap-statistics.ts +++ b/tests/corpus/v8-heap-statistics.ts @@ -14,3 +14,10 @@ console.log(stats.used_heap_size <= stats.total_heap_size); console.log(stats.heap_size_limit > 0, stats.total_available_size <= stats.heap_size_limit); console.log(stats.does_zap_garbage === 0 || stats.does_zap_garbage === 1); console.log(stats.number_of_native_contexts >= 1, warm.length); + +// external_memory is process.memoryUsage().external: it grows by the +// storage of a new ArrayBuffer. +const before = v8.getHeapStatistics().external_memory; +const storage = new Uint8Array(4 * 1024 * 1024); +const after = v8.getHeapStatistics(); +console.log(after.external_memory - before >= storage.byteLength, after.external_memory <= process.memoryUsage().external); diff --git a/tests/harness/corpus-costs/process-memory-usage-array-buffers-typed.ts.json b/tests/harness/corpus-costs/process-memory-usage-array-buffers-typed.ts.json new file mode 100644 index 000000000..ca7c3fc00 --- /dev/null +++ b/tests/harness/corpus-costs/process-memory-usage-array-buffers-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 5463, "san": 4978 } diff --git a/tests/harness/corpus-costs/process-memory-usage-array-buffers.cjs.json b/tests/harness/corpus-costs/process-memory-usage-array-buffers.cjs.json new file mode 100644 index 000000000..c718d14d3 --- /dev/null +++ b/tests/harness/corpus-costs/process-memory-usage-array-buffers.cjs.json @@ -0,0 +1 @@ +{ "plain": 4864, "san": 3953 } From d67f378a5048b1afa4c03bc1fa94d9a8f52eb0c7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 13:44:32 -0500 Subject: [PATCH 150/193] Release the web view of an abandoned fetch body while its transfer runs A Response body's stream gets a web ReadableStream view when a member the fetch stream lacks is read (for example `body.tee`). The view's source holds the stream, and the stream released the view only when that source was its last holder. While the transfer runs it holds the stream too, so a dropped body with a view kept a reference count of 2: the unobserved-response discard never ran, the paused socket was never resumed, and a compressed response dropped before HTTP EOF kept the process alive. The stream now releases the view when only the view's source and the live transfer hold it, and then takes the unobserved-response discard, as a body without a view does. The static-abandon-view fixture reads `body.tee` and `body.locked` of a 4 MiB incompressible gzip response from the new /gzip-incompressible route and drops it. It hung until the test's timeout before this change and now exits as Node does. --- packages/runtime/src/scr_fetch.c | 10 ++++++---- tests/fixtures/fetch/servers.mjs | 18 +++++++++++++++++- .../fetch/static-abandon-view/main.mjs | 10 ++++++++++ tests/harness/fetch.test.ts | 13 +++++++++---- 4 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/fetch/static-abandon-view/main.mjs diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index c64144ab7..eda6f3210 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -934,10 +934,12 @@ static void sf_stream_release(SfStream *s) { if (!s) return; if (--s->rc > 0) { #ifdef SCR_HANDLE_CLASSES - /* Only the web view's source still holds the stream: the view no - * longer needs to be found from it, and the view, which reads it, - * keeps it while anything else holds the view. */ - if (s->rc == 1 && s->view_source_live && s->web_view) { + /* Only the web view's source still holds the stream, with the transfer + * that fills it while the response runs: the view no longer needs to be + * found from it, and the view, which reads it, keeps it while anything + * else holds the view. A view nothing else holds releases its source, + * and the stream then takes the unobserved-response discard below. */ + if (s->rc == (s->response_owner ? 2u : 1u) && s->view_source_live && s->web_view) { ScrDyn *view = s->web_view, *lock = s->web_view_lock; s->web_view = s->web_view_lock = NULL; scr_dyn_release(lock); diff --git a/tests/fixtures/fetch/servers.mjs b/tests/fixtures/fetch/servers.mjs index d3f04b153..17034b03e 100644 --- a/tests/fixtures/fetch/servers.mjs +++ b/tests/fixtures/fetch/servers.mjs @@ -22,12 +22,13 @@ * /early-hints * /switching-protocols /invalid-utf8 /slow /drip * /chunked /backpressure /backpressure-state /gzip /gzip-concat - * /gzip-truncated /gzip-pressure /deflate + * /gzip-truncated /gzip-pressure /gzip-incompressible /deflate * /status-meta /no-content /reset-content /reset-content-large * /truncated-response /upload-failure /sse, * 404 for the rest; * the proxy relays absolute-URI requests and CONNECT tunnels, counting one * per proxied request either way. */ +import { createCipheriv } from "node:crypto"; import { readFileSync } from "node:fs"; import { createServer, request } from "node:http"; import { createServer as createHttpsServer } from "node:https"; @@ -39,6 +40,7 @@ export async function startFetchServers() { const fragmentRedirects = new Set(); const backpressureStates = new Map(); let gzipPressurePayload; + let gzipIncompressiblePayload; const server = createServer((req, res) => { const url = req.url ?? "/"; if (url === "/upload-failure") { @@ -292,6 +294,20 @@ export async function startFetchServers() { "content-length": String(gzipPressurePayload.length), }); res.end(gzipPressurePayload); + } else if (url === "/gzip-incompressible") { + // 4 MiB of fixed pseudo-random bytes: the representation spans many + // socket reads, so an unread body pauses the socket before HTTP EOF. + gzipIncompressiblePayload ??= gzipSync( + createCipheriv("aes-128-ctr", Buffer.alloc(16), Buffer.alloc(16)).update( + Buffer.alloc(4 * 1024 * 1024), + ), + ); + res.writeHead(200, { + "content-type": "application/octet-stream", + "content-encoding": "gzip", + "content-length": String(gzipIncompressiblePayload.length), + }); + res.end(gzipIncompressiblePayload); } else if (url === "/deflate") { // zlib-wrapped deflate (Node servers' `deflate` spelling). const payload = deflateSync(Buffer.from("deflated wörld", "utf8")); diff --git a/tests/fixtures/fetch/static-abandon-view/main.mjs b/tests/fixtures/fetch/static-abandon-view/main.mjs new file mode 100644 index 000000000..862d5769c --- /dev/null +++ b/tests/fixtures/fetch/static-abandon-view/main.mjs @@ -0,0 +1,10 @@ +// A compressed body whose web-stream members were read, then dropped unread +// with its Response before HTTP EOF, must settle the native transfer: the +// body's ReadableStream view does not keep it alive. Otherwise its paused +// socket keeps the process alive while the server waits to send the rest. +async function inspect() { + const response = await fetch(`${process.argv[2]}/gzip-incompressible`); + return [typeof response.body.tee, response.body.locked]; +} +console.log(...(await inspect())); +console.log("abandoned inspected compressed response"); diff --git a/tests/harness/fetch.test.ts b/tests/harness/fetch.test.ts index 1df7965f6..f6a4f1392 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -194,10 +194,15 @@ describe(`static fetch differential${sanitize ? " (sanitized)" : ""}`, () => { 120_000, ); - test.for(["llvm"] as const)( - "static fetch / %s backend settles an abandoned compressed response", - async () => { - const entry = join(fixturesRoot, "static-abandon/main.mts"); + // static-abandon-view reads web-stream members of the body first, so the + // body has a ReadableStream view when it is dropped. + test.for([ + ["static-abandon/main.mts", "llvm"], + ["static-abandon-view/main.mjs", "llvm"], + ] as const)( + "static fetch / %s / %s backend settles an abandoned compressed response", + async ([fixture]) => { + const entry = join(fixturesRoot, fixture); const binary = await build(entry); const [nodeRes, nativeRes] = await Promise.all([ runBinary("node", [entry, baseUrl], undefined, 3_000), From b8e678b765b0fac318c5b20e3931457bc5896ebc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 14:48:45 -0500 Subject: [PATCH 151/193] Evaluate a drain name expression, and discard a write callback's return value events.once(process.stdout, name) with a name typed "drain" returned the pending promise without evaluating the name, so a call or comma expression producing "drain" never ran. A name that is not a string literal now runs before the promise is created. A write completion callback that returns a value (`() => count++`, a stored function typed `(): number`) reported SC2020. The stream ignores the return value, so such a callback is adapted to return nothing, as before the error-argument form was added. The stream-shims npm case compiles and matches Node now that write callbacks take the error argument, so its SC2020 expectation is removed. --- .../frontend/lowering/lower-event-emitter.ts | 19 ++++++++++++++++++- .../src/frontend/lowering/lower-stream.ts | 8 +++++++- tests/corpus/process-stream-drain.ts | 10 ++++++++++ tests/corpus/writable-error-callbacks.ts | 13 +++++++++++++ tests/harness/npm-suite.ts | 1 - 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index be6ee9c75..b9c71715e 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2549,7 +2549,24 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc: nameT.value === "drain" && args.length === 2 ) { - return { kind: "libCall", fn: "events.pendingForever", args: [], type: resultT, loc }; + const pending: IrExpr = { + kind: "libCall", + fn: "events.pendingForever", + args: [], + type: resultT, + loc, + }; + let name = args[1]!; + while (ts.isParenthesizedExpression(name)) name = name.expression; + if (ts.isStringLiteralLike(name)) return pending; + // A name expression typed "drain" still runs (`pick()`, `(log(), "drain")`). + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(args[1]!), loc }], + result: pending, + type: resultT, + loc, + }; } } // A checked-dynamic NodeJS stream slot (dyn-streams.ts): a native stream diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 6d203ae7f..5da73ed0a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6964,7 +6964,13 @@ export function lowerStreamMethodCall( const kind = chunkKind(args[0]!); if (kind === "null") lowerer.noLowering("write(null)", call, "end() finishes a writable"); const lowerWriteCb = (cbArg: ts.Expression): IrExpr => { - const cb = lowerer.lowerExpr(cbArg); + // The stream ignores what the callback returns (`() => count++`, a + // stored function returning a value): the value is discarded. + const raw = lowerer.lowerExpr(cbArg); + const cb = + raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1 + ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID)) + : raw; if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.noLowering( "write completion callbacks of this shape", diff --git a/tests/corpus/process-stream-drain.ts b/tests/corpus/process-stream-drain.ts index aa711edca..24bd995f7 100644 --- a/tests/corpus/process-stream-drain.ts +++ b/tests/corpus/process-stream-drain.ts @@ -43,4 +43,14 @@ void (async () => { console.log("never"); })(); +// A name expression typed "drain" still runs. +let picks = 0; +const drainName = (): "drain" => { + picks++; + return "drain"; +}; +void once(process.stdout, drainName()); +void once(process.stderr, (picks++, "drain")); +console.log("picks", picks); + setTimeout(() => console.log("drained", drained), 5); diff --git a/tests/corpus/writable-error-callbacks.ts b/tests/corpus/writable-error-callbacks.ts index af0c66a4b..0eaf0c33f 100644 --- a/tests/corpus/writable-error-callbacks.ts +++ b/tests/corpus/writable-error-callbacks.ts @@ -51,3 +51,16 @@ const plain = new Writable({ }, }); plain.write("x", () => console.log("plain done")); + +// Callbacks that return a value: the stream discards it. +let count = 0; +const counted = (): number => ++count; +plain.write("y", counted); +const tally = (error?: Error | null): number => (error ? -1 : ++count); +const counting = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(null); + }, +}); +counting.write("tally", tally); +setTimeout(() => console.log("count", count), 5); diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index d31d96ac9..5457ab7f3 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -107,7 +107,6 @@ const KNOWN: Record = { "package-imports": { island: true }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { run: "SC2020" }, "util-shims": { run: "SC2020" }, // The package redeclares the var it exported: Node copies the first value. "var-export-redeclared": { island: true }, From f3ed70667eb0137cd9c04c225d48942741b4f01f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 14:57:26 -0500 Subject: [PATCH 152/193] Count the small roots a partition takes toward its cap A root under one 4 KiB unit went to the first open partition from its name's whose load from the larger roots was below the cap. When only one open partition was below the cap, every such root went there, and that partition could hold most of the module, which then was optimized whole and lost the per-partition cache. The small roots a partition takes now count toward its cap for the small roots after them, and a small root that finds no partition below the cap goes to its name's. The larger roots' placement still ignores the small roots, so a helper that an edit adds or removes moves no larger root. --- native/llvm-codegen/src/partition.cpp | 16 +++-- .../compiler/test/native-partitions.test.ts | 60 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp index c196fed96..9ab1ce657 100644 --- a/native/llvm-codegen/src/partition.cpp +++ b/native/llvm-codegen/src/partition.cpp @@ -256,22 +256,26 @@ void balanceRoots(std::vector &Symbols, ArrayRef Roots, if (!EntryRoots.contains(Root) && RootBytes[Root] >= Unit && RootBytes[Root] * 2 <= Cap) Place(Root); - // Roots under one unit (runtime helpers, mostly) go to the first open - // partition from their names' whose load from the larger roots leaves - // room, and count in no load: where a helper goes depends only on the - // larger roots, so a helper that an edit adds or removes moves no other - // root. + // Roots under one unit (runtime helpers, mostly) count in no load of the + // larger roots, so a helper that an edit adds or removes moves no larger + // root. Each goes to the first open partition from its name's whose load, + // the helpers placed there included, is below the cap, or else to its + // name's: the helpers fill the partitions with room, and once those are + // full they spread by name instead of gathering in one partition (which + // could then hold most of the module and have it optimized whole). + std::vector SmallLoad(Count, 0); for (unsigned Root : Order) if (!EntryRoots.contains(Root) && RootBytes[Root] < Unit) { unsigned Home = stableHash(Symbols[Root].Name) % Count; unsigned Chosen = FirstOpen(Home); for (unsigned K = 0; K < Count; ++K) { unsigned P = (Home + K) % Count; - if (!Closed[P] && Load[P] < Cap) { + if (!Closed[P] && Load[P] + SmallLoad[P] < Cap) { Chosen = P; break; } } + SmallLoad[Chosen] += RootBytes[Root]; Placed[Root] = Chosen; } for (unsigned I = 0; I < Symbols.size(); ++I) diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index 215f6a718..01a6a6f50 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -940,6 +940,66 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { expect(changed.length).toBeLessThanOrEqual(2); }); + test("small helpers spread when one partition is left below the cap", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-small-spread-")); + dirs.push(dir); + // Three exported functions of about 16 KiB, each over the cap of four + // partitions, whose names hash to partitions 3, 0 and 1, and forty + // exported helpers of about 2.5 KiB. Partition 2 is the only one left + // below the cap; counted only against the larger roots' loads, every + // helper would go there. + const chain = (header: string, lines: number) => [ + header, + ...Array.from( + { length: lines }, + (_, step) => ` %v${step} = add i64 ${step === 0 ? "%value" : `%v${step - 1}`}, 1`, + ), + ` ret i64 %v${lines - 1}`, + "}", + ]; + const helpers = 40; + const lines: string[] = []; + for (let index = 0; index < 3; index++) + lines.push(...chain(`define i64 @big${index}(i64 %value) noinline {`, 640)); + for (let index = 0; index < helpers; index++) + lines.push(...chain(`define i64 @helper${index}(i64 %value) noinline {`, 100)); + lines.push("define i32 @main() {"); + const calls = [ + ...[0, 1, 2].map((i) => `big${i}`), + ...Array.from({ length: helpers }, (_, i) => `helper${i}`), + ]; + calls.forEach((name, index) => + lines.push( + ` %r${index} = call i64 @${name}(i64 ${index})`, + ` %s${index} = add i64 ${index === 0 ? "0" : `%s${index - 1}`}, %r${index}`, + ), + ); + lines.push( + ` %low = urem i64 %s${calls.length - 1}, 251`, + " %code = trunc i64 %low to i32", + " ret i32 %code", + "}", + ); + let total = 3 * 640 + helpers * 100; + calls.forEach((_, index) => (total += index)); + const objects = await emit(dir, lines.join("\n") + "\n", { + outputs: 4, + optLevel: "0", + name: "spread", + }); + expect(await exitCode(dir, objects, "spread")).toBe(total % 251); + const homes = new Map(); + for (const [index, object] of objects.entries()) { + const { stdout } = await execFileAsync("nm", ["--defined-only", object]); + for (const line of stdout.split("\n")) + if (/^[0-9a-fA-F]+ [A-Za-z] _?helper\d+$/.test(line.trim())) + homes.set(index, (homes.get(index) ?? 0) + 1); + } + expect([...homes.values()].reduce((sum, count) => sum + count, 0)).toBe(helpers); + expect(Math.max(...homes.values())).toBeLessThan(helpers); + expect(homes.size).toBeGreaterThan(1); + }); + test("a table written one element per line keeps the text partitioner and its reuse", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-table-")); dirs.push(dir); From 9632906b4dc61dbe3009f36a0c00f99cf517e649 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 14:59:10 -0500 Subject: [PATCH 153/193] Take from a Readable's buffered queue by moving its head index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking the head entry of a Readable's buffer moved every remaining entry down one slot, so draining a queue of n buffered chunks cost O(n²). The buffer now keeps the index of its first entry: a take advances it, a push at the tail moves the entries down only when at least half the slots are free at the front (and grows otherwise), and unshift takes a freed front slot when there is one. The drop, trace and boxing loops walk the entries from that index. The stream-buffered-drain runtime workload buffers 200,000 objects and 100,000 Buffers before draining them; corpus 8533 covers reads across entries, unshift into a freed slot and onto a part-read head, setEncoding after takes, and object-mode takes, unshifts and pushes. --- .../workloads/stream-buffered-drain.json | 4 + .../workloads/stream-buffered-drain.ts | 36 +++++++++ .../8533-stream-buffer-queue-index.ts.json | 6 ++ packages/runtime/src/scr_stream.c | 76 ++++++++++++------- scripts/zero-cost-compare.mjs | 5 +- .../corpus/8533-stream-buffer-queue-index.ts | 33 ++++++++ 6 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 benchmarks/runtime/workloads/stream-buffered-drain.json create mode 100644 benchmarks/runtime/workloads/stream-buffered-drain.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8533-stream-buffer-queue-index.ts.json create mode 100644 tests/corpus/8533-stream-buffer-queue-index.ts diff --git a/benchmarks/runtime/workloads/stream-buffered-drain.json b/benchmarks/runtime/workloads/stream-buffered-drain.json new file mode 100644 index 000000000..e1746d52b --- /dev/null +++ b/benchmarks/runtime/workloads/stream-buffered-drain.json @@ -0,0 +1,4 @@ +{ + "entry": "workloads/stream-buffered-drain.ts", + "args": [] +} diff --git a/benchmarks/runtime/workloads/stream-buffered-drain.ts b/benchmarks/runtime/workloads/stream-buffered-drain.ts new file mode 100644 index 000000000..62f89833b --- /dev/null +++ b/benchmarks/runtime/workloads/stream-buffered-drain.ts @@ -0,0 +1,36 @@ +// Readable streams that buffer many chunks before anyone reads them, then +// drain: 200,000 objects pushed into an object-mode Readable and taken +// with read() in a 'readable' handler, and 100,000 small Buffers pushed +// into a byte-mode Readable and piped into a Writable. +import { Readable, Writable } from "node:stream"; + +const objects = new Readable({ objectMode: true, highWaterMark: 16, read() {} }); +for (let i = 0; i < 200000; i++) objects.push({ id: i, qty: i % 9 }); +objects.push(null); + +let taken = 0; +let qty = 0; +objects.on("readable", () => { + let row: { id: number; qty: number } | null; + while ((row = objects.read() as { id: number; qty: number } | null) !== null) { + taken++; + qty += row.qty; + } +}); +objects.on("end", () => { + console.log(`objects taken=${taken} qty=${qty}`); + const bytes = new Readable({ read() {} }); + for (let i = 0; i < 100000; i++) bytes.push(Buffer.from([i & 0xff, (i >> 8) & 0xff, 7])); + bytes.push(null); + let length = 0; + let sum = 0; + const sink = new Writable({ + write(chunk: Buffer, _enc, cb) { + length += chunk.length; + for (const b of chunk) sum = (sum + b) % 65521; + cb(); + }, + }); + sink.on("finish", () => console.log(`bytes length=${length} sum=${sum}`)); + bytes.pipe(sink); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8533-stream-buffer-queue-index.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8533-stream-buffer-queue-index.ts.json new file mode 100644 index 000000000..124a412c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8533-stream-buffer-queue-index.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8533-stream-buffer-queue-index.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 872586976..8bffafd86 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -113,8 +113,11 @@ struct ScrStreamState { * counts CHARS consumed of buf[0] instead of bytes. */ struct { size_t hwm, length; - void **buf; /* queue of owned chunks; buf[0] read from head_off */ - size_t n, cap, head_off; + /* queue of owned chunks: the n entries from buf[first], the first one + * read from head_off. Taking the head advances first instead of + * shifting the rest; pushes reuse the freed front slots. */ + void **buf; + size_t first, n, cap, head_off; int flowing; /* -1 null, 0 paused, 1 flowing */ bool reading, in_read_sync, ended, end_emitted, end_scheduled; bool in_read_call; /* inside the user's _read (in_read_sync also starts true) */ @@ -411,7 +414,7 @@ static void scr_stream_state_drop(ScrStreamState *st, bool gc) { if (!st) return; if (st->errored) scr_error_release(st->errored); if (st->native) st->native_release(st->native); - for (size_t i = 0; i < st->r.n; i++) { + for (size_t i = st->r.first; i < st->r.first + st->r.n; i++) { /* The collector frees the dyn entries it traced. */ if (gc && st->r.dyn_entries && scr_stream_dyn_entry_traced(st->r.buf[i])) continue; scr_stream_entry_release(st, st->r.buf[i]); @@ -467,7 +470,7 @@ void scr_stream_st_gcfree(ScrStreamState *st) { scr_stream_state_drop(st, true); void scr_stream_st_trace(ScrStreamState *st, ScrTraceVisit visit, void *ctx) { if (!st) return; if (st->r.dyn_entries) { - for (size_t i = 0; i < st->r.n; i++) { + for (size_t i = st->r.first; i < st->r.first + st->r.n; i++) { if (scr_stream_dyn_entry_traced(st->r.buf[i])) visit(st->r.buf[i], ctx); } } @@ -783,33 +786,52 @@ static void scr_stream_rbuf_push(ScrStreamState *st, void *chunk /*moves*/, ScrE fputs("scriptc: internal error: stream buffer entry of the wrong kind\n", stderr); abort(); } - if (st->r.n == st->r.cap) { - st->r.cap = st->r.cap ? st->r.cap * 2 : 4; - st->r.buf = realloc(st->r.buf, st->r.cap * sizeof *st->r.buf); - if (!st->r.buf) scr_stream_oom(); + /* unshift: head_off must be zero (only the head entry is partially + * consumed, and unshift with a part-read head keeps the remainder intact + * by materializing it first — the caller slices). A freed front slot + * takes it directly. */ + if (front && st->r.first > 0) { + st->r.buf[--st->r.first] = chunk; + st->r.n++; + st->r.length += scr_stream_entry_len(st, chunk); + return; + } + if (st->r.first + st->r.n == st->r.cap) { + if (st->r.first > 0 && st->r.first >= st->r.n) { + /* at least half the slots are free at the front: move the entries + * down instead of growing */ + memmove(st->r.buf, st->r.buf + st->r.first, st->r.n * sizeof *st->r.buf); + st->r.first = 0; + } else { + st->r.cap = st->r.cap ? st->r.cap * 2 : 4; + st->r.buf = realloc(st->r.buf, st->r.cap * sizeof *st->r.buf); + if (!st->r.buf) scr_stream_oom(); + } } if (front) { - /* unshift: head_off must be zero (only buf[0] is partially consumed, - * and unshift with a part-read head keeps the remainder intact by - * materializing it first — the caller slices). */ memmove(st->r.buf + 1, st->r.buf, st->r.n * sizeof *st->r.buf); st->r.buf[0] = chunk; } else { - st->r.buf[st->r.n] = chunk; + st->r.buf[st->r.first + st->r.n] = chunk; } st->r.n++; st->r.length += scr_stream_entry_len(st, chunk); } +/* The head entry leaves the queue (its reference moves to the caller). */ +static void scr_stream_rbuf_drop_head(ScrStreamState *st) { + st->r.n--; + st->r.first = st->r.n == 0 ? 0 : st->r.first + 1; +} + /* Takes n units (n <= length; bytes, or chars once encoded) off the * buffer head as one +1 Buffer / string. An object-mode buffer always * takes one whole entry (n is 1). */ static void *scr_stream_rbuf_take(ScrStreamState *st, size_t n) { - void *first = st->r.n > 0 ? st->r.buf[0] : NULL; + void *first = st->r.n > 0 ? st->r.buf[st->r.first] : NULL; if (first && st->r.head_off == 0 && scr_stream_entry_len(st, first) == n) { /* whole-chunk fast path: hand the buffered chunk out as-is */ - memmove(st->r.buf, st->r.buf + 1, (st->r.n - 1) * sizeof *st->r.buf); - st->r.n--; + scr_stream_rbuf_drop_head(st); st->r.length -= n; return first; } @@ -819,7 +841,7 @@ static void *scr_stream_rbuf_take(ScrStreamState *st, size_t n) { ScrStr *out = NULL; size_t at = 0; while (at < n) { - ScrStr *head = st->r.buf[0]; + ScrStr *head = st->r.buf[st->r.first]; size_t head_len = (size_t)scr_str_utf16_len(head); size_t avail = head_len - st->r.head_off; size_t take = avail < n - at ? avail : n - at; @@ -836,8 +858,7 @@ static void *scr_stream_rbuf_take(ScrStreamState *st, size_t n) { st->r.head_off += take; if (st->r.head_off == head_len) { scr_str_release(head); - memmove(st->r.buf, st->r.buf + 1, (st->r.n - 1) * sizeof *st->r.buf); - st->r.n--; + scr_stream_rbuf_drop_head(st); st->r.head_off = 0; } } @@ -847,7 +868,7 @@ static void *scr_stream_rbuf_take(ScrStreamState *st, size_t n) { ScrBytes *out = scr_stream_buffer(scr_bytes_new(SCR_BYTES_U8, (double)n)); size_t at = 0; while (at < n) { - ScrBytes *head = st->r.buf[0]; + ScrBytes *head = st->r.buf[st->r.first]; size_t avail = head->len - st->r.head_off; size_t take = avail < n - at ? avail : n - at; scr_bytes_read(head, st->r.head_off, out->data + at, take); @@ -855,8 +876,7 @@ static void *scr_stream_rbuf_take(ScrStreamState *st, size_t n) { st->r.head_off += take; if (st->r.head_off == head->len) { scr_bytes_release(head); - memmove(st->r.buf, st->r.buf + 1, (st->r.n - 1) * sizeof *st->r.buf); - st->r.n--; + scr_stream_rbuf_drop_head(st); st->r.head_off = 0; } } @@ -1112,7 +1132,7 @@ static void *scr_stream_read_n_kind(ScrStream *s, double size, ScrEntryKind *kin want = st->r.length > 0 && (absent || n > 0) ? 1 : 0; } else if (absent) { want = st->r.flowing == 1 && st->r.n > 0 - ? scr_stream_entry_len(st, st->r.buf[0]) - st->r.head_off + ? scr_stream_entry_len(st, st->r.buf[st->r.first]) - st->r.head_off : st->r.length; } else { want = n <= st->r.length ? n : (st->r.ended ? st->r.length : 0); @@ -1136,7 +1156,7 @@ static void *scr_stream_read_n_kind(ScrStream *s, double size, ScrEntryKind *kin want = st->r.length > 0 && (absent || n > 0) ? 1 : 0; } else if (absent) { want = st->r.flowing == 1 && st->r.n > 0 - ? scr_stream_entry_len(st, st->r.buf[0]) - st->r.head_off + ? scr_stream_entry_len(st, st->r.buf[st->r.first]) - st->r.head_off : st->r.length; } else { want = n <= st->r.length ? n : (st->r.ended ? st->r.length : 0); @@ -1955,7 +1975,8 @@ ScrStream *scr_stream_new_object_readable(ScrClosure *read, ScrStreamReadInv rea * is boxed. */ static void scr_stream_rbuf_to_dyn(ScrStreamState *st) { ScrEntryKind kind = scr_stream_rkind(st); - for (size_t i = 0; i < st->r.n; i++) st->r.buf[i] = scr_stream_entry_box(st->r.buf[i], kind); + for (size_t i = st->r.first; i < st->r.first + st->r.n; i++) + st->r.buf[i] = scr_stream_entry_box(st->r.buf[i], kind); st->r.dyn_entries = true; st->r.object_kind_set = true; } @@ -6804,7 +6825,7 @@ void scr_stream_unshift(ScrStream *s, ScrBytes *chunk) { ScrStreamState *st = s->st; if (st->r.head_off > 0) { /* materialize the part-read head so the front slot is whole */ - void *rest = scr_stream_rbuf_take(st, scr_stream_entry_len(st, st->r.buf[0]) - st->r.head_off); + void *rest = scr_stream_rbuf_take(st, scr_stream_entry_len(st, st->r.buf[st->r.first]) - st->r.head_off); scr_stream_rbuf_push(st, rest, scr_stream_rkind(st), true); } scr_stream_add_chunk(s, chunk, true); @@ -7007,9 +7028,9 @@ ScrStream *scr_stream_set_encoding(ScrStream *s, ScrStr *enc) { st->r.dec_pending = 0; /* decode the buffered bytes into one concatenated string entry */ ScrStr *content = NULL; - size_t skip = st->r.head_off; /* buf[0] may be part-read (byte offset) */ + size_t skip = st->r.head_off; /* the head entry may be part-read (byte offset) */ for (size_t i = 0; i < st->r.n; i++) { - ScrBytes *chunk = st->r.buf[i]; + ScrBytes *chunk = st->r.buf[st->r.first + i]; ScrBytes *view = chunk; if (i == 0 && skip > 0) { view = scr_bytes_new(SCR_BYTES_U8, (double)(chunk->len - skip)); @@ -7042,6 +7063,7 @@ ScrStream *scr_stream_set_encoding(ScrStream *s, ScrStr *enc) { } } st->r.n = 0; + st->r.first = 0; st->r.head_off = 0; st->r.length = 0; /* AFTER the byte-entry walk above: from here every entry is a string, diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 40f7287fb..4aeccd347 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -416,7 +416,9 @@ export function nodeIslandSymbols(names) { * and results the checker types `any`. bench/record-untyped-writes, also * added later, writes typed records through `unknown` and `Object.assign` * views. bench/layered-errors, also added later, constructs and throws - * Error subclass instances, as bench/validate-errors does. Every other bench + * Error subclass instances, as bench/validate-errors does. + * bench/stream-buffered-drain, also added later, drains an object-mode + * Readable, whose chunks are checked-dynamic values. 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 @@ -443,6 +445,7 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/regex-replacers", "bench/record-untyped-writes", "bench/layered-errors", + "bench/stream-buffered-drain", ]); /** Names of the functions (text and weak symbols) in `nm` output. Mach-O diff --git a/tests/corpus/8533-stream-buffer-queue-index.ts b/tests/corpus/8533-stream-buffer-queue-index.ts new file mode 100644 index 000000000..493ebcbec --- /dev/null +++ b/tests/corpus/8533-stream-buffer-queue-index.ts @@ -0,0 +1,33 @@ +// A Readable's buffered queue after its head moved: read(n) across +// entries, unshift into the freed front slot and onto a part-read head, +// setEncoding after takes, and object-mode takes, unshifts and pushes that +// reuse the freed slots. +import { Readable } from "node:stream"; + +const bytes = new Readable({ read() {} }); +for (let i = 0; i < 40; i++) bytes.push(Buffer.from(`c${i};`)); +const out: string[] = []; +out.push(String(bytes.read(7))); +out.push(String(bytes.read(4))); +bytes.unshift(Buffer.from("U1;")); +out.push(String(bytes.read(5))); +bytes.unshift(Buffer.from("U2;")); +out.push(String(bytes.read(30))); +bytes.setEncoding("utf8"); +out.push(String(bytes.read(9))); +for (let i = 40; i < 60; i++) bytes.push(Buffer.from(`d${i};`)); +out.push(String(bytes.read(20))); +out.push(`left ${bytes.readableLength}`); +console.log(out.join("\n")); + +const objects = new Readable({ objectMode: true, highWaterMark: 4, read() {} }); +for (let i = 0; i < 12; i++) objects.push({ n: i }); +const seen: number[] = []; +for (let i = 0; i < 9; i++) seen.push((objects.read() as { n: number }).n); +objects.unshift({ n: 100 }); +objects.unshift({ n: 101 }); +for (let i = 12; i < 40; i++) objects.push({ n: i }); +let row: { n: number } | null; +while ((row = objects.read() as { n: number } | null) !== null) seen.push(row.n); +console.log(seen.join(",")); +console.log(`object left ${objects.readableLength}`); From 714385609e8e42d36c9f5499c1623c80a75ee1d9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 15:27:36 -0500 Subject: [PATCH 154/193] Call private static fields through the general value-call lowering A call through a function-valued private static field (this.#fn(...)) completed its arguments against the field's fixed parameter list, so a rest-parameter function lost the arguments for its rest parameter, and a function that reads arguments was refused. The call now takes the ordinary call-through-value lanes (typed rests, runtime-arity closures, checked-dynamic values) and binds this to the checked receiver. --- .../src/frontend/lowering/lower-calls.ts | 2 +- .../src/frontend/lowering/private-statics.ts | 44 +++++++------------ tests/corpus/js-private-static-receivers.js | 16 +++++++ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index a0e00179b..132e435cf 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -2127,7 +2127,7 @@ function callableInstanceStringCall( /** A call through an evaluated callee value: checked-dynamic callees, * function unions, typed-rest and runtime-arity closures. */ -function lowerCallThroughValue( +export function lowerCallThroughValue( lowerer: Lowerer, expr: ts.CallExpression, callee: IrExpr, diff --git a/packages/compiler/src/frontend/lowering/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts index 3ca360209..b8823a523 100644 --- a/packages/compiler/src/frontend/lowering/private-statics.ts +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -14,7 +14,7 @@ import { hasStaticThis } from "./static-this.js"; import type { Lowerer } from "./lowerer.js"; import { lowerClassStaticRead, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { omittedArgFor } from "./call-arguments.js"; +import { lowerCallThroughValue } from "./lower-calls.js"; /** How a static private name is used: a field read or write, or a method * (or accessor) read or call. V8 words each failure differently. */ @@ -252,7 +252,9 @@ export function lowerStaticPrivateCall( /** `R.#f(args)` for a function-valued static private field on a * checked-dynamic receiver: the receiver, the brand check (PrivateGet's), * then a call through the field's value with `this` bound to the receiver, - * as JavaScript calls a private field's function. */ + * as JavaScript calls a private field's function. The call takes the + * ordinary call-through-value lanes, so rest parameters, typed rests and + * checked-dynamic values receive every argument. */ function lowerStaticPrivateFieldCall( lowerer: Lowerer, call: ts.CallExpression, @@ -261,38 +263,22 @@ function lowerStaticPrivateFieldCall( field: ClassInfo["staticFields"][number], value: IrExpr, ): IrExpr { - const fieldType = field.type; - if (fieldType.kind !== "func") { + const loc = locOf(call); + const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); + const lowered = lowerCallThroughValue( + lowerer, + call, + varRef(field.globalId, field.type, loc), + loc, + ); + if (lowered.kind !== "callValue" && lowered.kind !== "dynCall") { return lowerer.unsupported( "SC1090", call, - `calling the private static field '${name}' when it does not hold a function of a known type`, + `this call of the private static field '${name}' (spread arguments)`, ); } - const loc = locOf(call); - const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); - const args = call.arguments.nodes.map((arg, index) => - lowerer.lowerExprExpecting(arg, fieldType.params[index]), - ); - for (let index = args.length; index < fieldType.params.length; index++) { - const absent = omittedArgFor(lowerer, fieldType.params[index]!, loc); - if (!absent) { - lowerer.unsupported( - "SC1090", - call, - "calls omitting a non-optional parameter of the callee's type", - ); - } - args.push(absent); - } - const result: IrExpr = { - kind: "callValue", - callee: varRef(field.globalId, fieldType, loc), - receiver: varRef(receiver.id, DYN, loc), - args, - type: fieldType.ret, - loc, - }; + const result: IrExpr = { ...lowered, receiver: varRef(receiver.id, DYN, loc) }; return { kind: "seqExpr", stmts: [ diff --git a/tests/corpus/js-private-static-receivers.js b/tests/corpus/js-private-static-receivers.js index 4dc626cf6..6d1b5da6d 100644 --- a/tests/corpus/js-private-static-receivers.js +++ b/tests/corpus/js-private-static-receivers.js @@ -164,3 +164,19 @@ try { } catch (error) { console.log("updates", error.constructor.name, error.message); } + +// A private static field holding a rest-parameter function, or one that +// reads `arguments`, receives every argument of a call through `this`. +class Rest { + static #sum = function (...xs) { + return (this === Rest ? "self " : "other ") + xs.length + ":" + xs.reduce((a, b) => a + b, 0); + }; + static #arrow = (first, ...rest) => first + "|" + rest.join(","); + static #plain = function (a, b) { + return [a, b, arguments.length].join("/"); + }; + static get run() { + return [this.#sum(1, 2, 3), this.#sum(), this.#arrow(1, 2, 3), this.#plain(1), this.#plain(1, 2, 3)].join(" ; "); + } +} +console.log("rest", Rest.run); From 6f019151b784452b2364eebd8afd69664d1fa2eb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 15:45:58 -0500 Subject: [PATCH 155/193] Test only the exponent of a masked value's shift and sum in speculative integers --- .../compiler/src/backend/llvm/emit-module.ts | 22 +++-- .../backend/llvm/map-number-lookup.test.ts | 18 +++-- .../src/backend/llvm/speculative-integers.ts | 80 +++++++++++++++++-- .../test/speculative-loop-emission.test.ts | 27 ++++++- .../corpus/speculative-mask-bounds.ts.json | 6 ++ tests/corpus/speculative-mask-bounds.ts | 55 +++++++++++++ .../speculative-mask-bounds.ts.json | 1 + 7 files changed, 187 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/speculative-mask-bounds.ts.json create mode 100644 tests/corpus/speculative-mask-bounds.ts create mode 100644 tests/harness/corpus-costs/speculative-mask-bounds.ts.json diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index fba260a63..b442c7921 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -1463,14 +1463,18 @@ function numberMapEntryHelpers(sz: string, loop: boolean): string[] { `miss:`, ` ret ptr null`, `slow:`, - ` %hashed = call ptr @sc_map_entry_f64_hashed_int${flavor}(ptr %m, i64 %ki)`, + ` %hashed = call ptr @sc_map_entry_f64_hashed_int${flavor}(ptr %m, i64 %ki, double %k)`, ` ret ptr %hashed`, `}`, ``, // Hashing (scr_map_find_f64): the SameValueZero key word and its - // scr_key_mix hash. sc_map_entry_f64_int's probe converts the integer - // view to the key word, which is never NaN or -0, and so skips the - // normalization. + // scr_key_mix hash. sc_map_entry_f64_int's probe takes the key's double + // with its integer view: its bits are the key word, never NaN, and +0 + // for the integer 0. The caller converts the integer: cvtsi2sd writes + // only the low half of its register, so in a function entry, where + // LLVM cannot clear the register first, it waits for the register's + // last value, in a loop of lookups often a value the previous + // iteration loaded. ...hashedNumberProbe( `sc_map_entry_f64_hashed${flavor}`, loop, @@ -1488,10 +1492,14 @@ function numberMapEntryHelpers(sz: string, loop: boolean): string[] { ...hashedNumberProbe( `sc_map_entry_f64_hashed_int${flavor}`, loop, - "i64 %ki", + "i64 %ki, double %k", sz, - [` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`], - "%kf", + [ + ` %kzero = icmp eq i64 %ki, 0`, + ` %kbits = bitcast double %k to i64`, + ` %kb = select i1 %kzero, i64 0, i64 %kbits`, + ], + "%k", ), ]; } diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts index 5e1e006d9..05af8f77b 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts @@ -212,23 +212,27 @@ test("a key with an integer view tests the direct index on the integer", () => { integral.module, )![0]; // An unsigned compare also rejects negative keys; the hashed probe takes - // the integer. + // the integer and its double. expect(helper).toContain("icmp ult i64 %ki, %nd"); expect(helper).toContain(`getelementptr inbounds i32, ptr %dense, ${sz} %i`); - expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)"); + expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki, double %k)"); expect(helper).toContain("br i1 %inside, label %slot, label %slow"); expect(helper).not.toContain("fptosi"); - // Its key word is the integer's double, which needs no NaN or -0 - // normalization; the runtime lookup gets that double. + // Its key word is the bits of the integer's double, which needs no NaN + // normalization and is +0 for the integer 0; the runtime lookup gets + // that double. The probe converts nothing itself (cvtsi2sd would wait + // for its register's last value). const hashed = - /^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki\) noinline [^]*?^}/m.exec( + /^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki, double %k\) noinline [^]*?^}/m.exec( integral.module, )![0]; - expect(hashed).toContain("sitofp i64 %ki to double"); + expect(hashed).toContain("bitcast double %k to i64"); + expect(hashed).toContain("select i1 %kzero, i64 0, i64 %kbits"); + expect(hashed).not.toContain("sitofp"); expect(hashed).not.toContain("fcmp"); expect(hashed).toContain("mul i64 %x1, -4658895280553007687"); expect(hashed).toContain( - "call ptr @scr_map_entry_f64(ptr %m, double %kf) memory(read) nounwind willreturn", + "call ptr @scr_map_entry_f64(ptr %m, double %k) memory(read) nounwind willreturn", ); // A fractional key and a key without a proof keep the double lookup. for (const key of [{ kind: "numLit", value: 0.5, type: F64, loc } as IrExpr, ref("k", F64)]) { diff --git a/packages/compiler/src/backend/llvm/speculative-integers.ts b/packages/compiler/src/backend/llvm/speculative-integers.ts index 1a2b220b0..f29ea1f47 100644 --- a/packages/compiler/src/backend/llvm/speculative-integers.ts +++ b/packages/compiler/src/backend/llvm/speculative-integers.ts @@ -234,6 +234,17 @@ function powerOfTwo(e: IrExpr): boolean { /** Typed-array element kinds whose elements are integers. */ const INTEGER_ELEMENTS = new Set(["u8", "u8c", "i8", "u16", "i16", "u32", "i32"]); +/** The values of each integer element kind. */ +const ELEMENT_RANGES: Record = { + u8: { min: 0, max: 255 }, + u8c: { min: 0, max: 255 }, + i8: { min: -128, max: 127 }, + u16: { min: 0, max: 65535 }, + i16: { min: -32768, max: 32767 }, + u32: { min: 0, max: 2 ** 32 - 1 }, + i32: { min: -(2 ** 31), max: 2 ** 31 - 1 }, +}; + /** A typed-array element read of an integer kind with no effect: an * out-of-range read gives NaN, which fails its check. Float elements would * only bail. */ @@ -904,6 +915,17 @@ export function emitSafeIntegerCheck(host: LlvmEmitterContext, value: string): [ return [integer, ok]; } +/** The condition that a bounded value's exponent is above `limit`. A + * value test it replaces can fail only past that limit; testing the + * exponent alone, which is usually the same in every iteration of a loop, + * keeps the value out of the test. Past the limit the write bails to the + * double copy, also for a value the replaced test would have passed. */ +function exponentAbove(host: LlvmEmitterContext, bound: MaskBound, limit: number): string { + const above = host.B.tmp(); + host.B.line(`${above} = icmp sgt i64 ${bound.exponent}, ${limit}`); + return above; +} + /** A condition that is true when an i64 lies outside the safe range. */ function unsafeInteger(host: LlvmEmitterContext, value: string): string { const B = host.B; @@ -932,6 +954,29 @@ function outsideRange(host: LlvmEmitterContext, value: string, low: number, high return outside; } +/** The range of an operand once its speculative evaluation continued: a + * literal's value, the range analysis' proof (well inside the safe + * integers), or for an integer element read, which bails on the NaN of an + * out-of-range index, the element kind's values. */ +function operandRange( + host: LlvmEmitterContext, + e: IrExpr, +): { min: number; max: number } | undefined { + const range = staticRange(host, e); + if (range || !isSpeculativeByteRead(e) || e.kind !== "bytesIntrinsic") return range; + return e.receiver.type.kind === "bytes" ? ELEMENT_RANGES[e.receiver.type.elem] : undefined; +} + +/** An i64 the speculative evaluation has bounded to [0, 2^(exponent + + * shift)): the floor-mod of a value by 2^exponent, shifted left by `shift`. + * `exponent` is an i64 already checked to be in [0, 52]; in a loop it is + * usually the same value on every iteration, so a test on it alone is + * loop-invariant. */ +interface MaskBound { + exponent: string; + shift: number; +} + /** A literal's value or the range analysis' proof for an expression whose * range lies well inside the safe integers. */ function staticRange( @@ -962,6 +1007,7 @@ export function emitSpeculativeInteger( bail: (condition: string) => void, reads: ReadonlyMap = new Map(), integral = false, + bounds = new Map(), ): string { const B = host.B; // A value the range analysis already proves takes the ordinary integer @@ -1000,12 +1046,13 @@ export function emitSpeculativeInteger( // bits, also for a negative value: in [0, 2^n), and never -0. const mod = floorModPowerOfTwo(e); if (mod) { - const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral); + const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral, bounds); const n = exponentOf(host, mod.power, bail, reads, integral); const mask = B.tmp(), low = B.tmp(); B.line(`${mask} = sub i64 ${shiftOne(host, n)}, 1`); B.line(`${low} = and i64 ${value}, ${mask}`); + bounds.set(low, { exponent: n, shift: 0 }); return low; } // A product with 2^n shifts: it is safe iff the other factor's @@ -1013,8 +1060,19 @@ export function emitSpeculativeInteger( // integer and a positive power is +0. if (e.op === "*" && (powerOfTwo(e.right) || powerOfTwo(e.left))) { const [factor, power] = powerOfTwo(e.right) ? [e.left, e.right] : [e.right, e.left]; - const value = emitSpeculativeInteger(host, factor, bail, reads, integral); + const value = emitSpeculativeInteger(host, factor, bail, reads, integral, bounds); const n = exponentOf(host, power, bail, reads, integral); + // A factor below 2^(m + s) times 2^k is below 2^(m + s + k), so + // safe when m + s + k <= 53. + const bound = bounds.get(value); + const k = power.kind === "numLit" ? exactPowerOfTwo(power.value) : null; + if (bound && k !== null) { + bail(exponentAbove(host, bound, 53 - bound.shift - k)); + const product = B.tmp(); + B.line(`${product} = shl nuw nsw i64 ${value}, ${n}`); + bounds.set(product, { exponent: bound.exponent, shift: bound.shift + k }); + return product; + } const limit = B.tmp(), magnitude = B.tmp(), outside = B.tmp(), @@ -1027,16 +1085,24 @@ export function emitSpeculativeInteger( B.line(`${product} = shl nsw i64 ${value}, ${n}`); return product; } - const a = emitSpeculativeInteger(host, e.left, bail, reads, integral); - const b = emitSpeculativeInteger(host, e.right, bail, reads, integral); + const a = emitSpeculativeInteger(host, e.left, bail, reads, integral, bounds); + const b = emitSpeculativeInteger(host, e.right, bail, reads, integral, bounds); const result = B.tmp(); if (e.op === "+" || e.op === "-") { // With one operand's range known, the other is checked against the // narrowed bounds before the operation: the check then depends on // no new value, and LLVM keeps the operation itself simple. - const right = staticRange(host, e.right); - const left = e.op === "+" ? staticRange(host, e.left) : undefined; - if (right) { + const right = operandRange(host, e.right); + const left = e.op === "+" ? operandRange(host, e.left) : undefined; + // A bounded operand, in [0, 2^(m + s)), plus one in [lo, hi] with + // |lo|, hi <= 2^52 is safe when m + s <= 52. + const bound = (value: string): MaskBound | undefined => + e.op === "+" ? bounds.get(value) : undefined; + const aBound = right ? bound(a) : undefined; + const bBound = !right && left ? bound(b) : undefined; + if (aBound) bail(exponentAbove(host, aBound, 52 - aBound.shift)); + else if (bBound) bail(exponentAbove(host, bBound, 52 - bBound.shift)); + else if (right) { // a + b with b in [lo, hi] is safe iff a in [-SAFE - lo, SAFE - hi]; // a - b iff a in [-SAFE + hi, SAFE + lo]. const low = e.op === "+" ? -SAFE - right.min : -SAFE + right.max; diff --git a/packages/compiler/test/speculative-loop-emission.test.ts b/packages/compiler/test/speculative-loop-emission.test.ts index 52b3c0c07..0ad4e4620 100644 --- a/packages/compiler/test/speculative-loop-emission.test.ts +++ b/packages/compiler/test/speculative-loop-emission.test.ts @@ -177,13 +177,38 @@ console.log(key(new Uint8Array([1, 2, 3, 0]), 6)); expect(fast.length).toBeLessThan(fn.length); // trim(k, bits): the low `bits` bits; * 4: a shift by two. expect(fast).toMatch(/= sub i64 %\S+, 1\n\s+%\S+ = and i64 %\S+, %\S+\n/); - expect(fast).toMatch(/= shl nsw i64 %\S+, 2\n/); + expect(fast).toMatch(/= shl (nuw )?nsw i64 %\S+, 2\n/); // No floor division of k (an arithmetic shift of it) and no checked // multiply remain. expect(fast).not.toMatch(/ashr i64 %/); expect(fast).not.toContain("@llvm.smul.with.overflow.i64"); }); +test("a masked value's shift and its sum with a byte test only the mask's exponent", async () => { + const llvm = await emitLlvm(` +function trim(value: number, bits: number): number { + return value - Math.floor(value / Math.pow(2, bits)) * Math.pow(2, bits); +} +function key(bytes: Uint8Array, bits: number): number { + let k = 0; + for (let i = 0; i < bytes.length; i++) k = trim(k, bits) * 4 + bytes[i]!; + return k; +} +console.log(key(new Uint8Array([1, 2, 3, 0]), 6)); +`); + const fn = /^define internal double @sc_bf_\w*key[\w.]*\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const fast = fn.slice(fn.search(/^spec\.fast\d+:/m)); + // trim(k, bits) is below 2^bits: times 4 it is safe for bits <= 51, and + // plus a byte (0 to 255) for bits <= 50. Only the exponent is tested. + const exponent = /= icmp sgt i64 (%\S+), 51\n/.exec(fast)?.[1]; + expect(exponent).toBeDefined(); + expect(fast).toContain(`= icmp sgt i64 ${exponent}, 50\n`); + expect(fast).toMatch(/= shl nuw nsw i64 %\S+, 2\n/); + // No magnitude test of the masked value and no safe-range test of the sum. + expect(fast).not.toContain("@llvm.abs.i64"); + expect(fast).not.toContain("18014398509481982"); +}); + test("a speculative loop reads its views' header fields once, before its copies", async () => { // A view held by a const local or by a parameter is the same view in // every iteration. Its data pointer is read before the branch to the fast diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/speculative-mask-bounds.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/speculative-mask-bounds.ts.json new file mode 100644 index 000000000..c004a1680 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/speculative-mask-bounds.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/speculative-mask-bounds.ts" + ], + "diags": [] +} diff --git a/tests/corpus/speculative-mask-bounds.ts b/tests/corpus/speculative-mask-bounds.ts new file mode 100644 index 000000000..a07ef9bff --- /dev/null +++ b/tests/corpus/speculative-mask-bounds.ts @@ -0,0 +1,55 @@ +// Speculative integer loops whose writes shift and add a value masked to +// its low bits: exponents up to 50 stay in the integer copy, and larger +// ones (51 and 52) keep the value tests, which bail to the double copy +// when a shift or a sum leaves the safe integers. +function trim(value: number, bits: number): number { + return value - Math.floor(value / Math.pow(2, bits)) * Math.pow(2, bits); +} + +function rolling(codes: Uint8Array, bits: number, scale: number): number { + let key = 2 ** 52 - 7; + let sum = 0; + for (let i = 0; i < codes.length; i++) { + key = trim(key, bits) * 4 + codes[i]!; + sum = (sum + (key % 1000003)) % 1000000007; + } + return sum * scale + (key % 999983); +} + +function signedBytes(codes: Int8Array, bits: number): number { + let key = 12345; + let sum = 0; + for (let i = 0; i < codes.length; i++) { + key = trim(key, bits) * 2 + codes[i]!; + sum = (sum + (key % 65521)) % 1000000007; + } + return sum; +} + +function wideWords(words: Uint32Array, bits: number): number { + let key = 1; + let sum = 0; + for (let i = 0; i < words.length; i++) { + key = trim(key, bits) * 8 + words[i]!; + sum = (sum + (key % 104729)) % 1000000007; + } + return sum; +} + +// A read past the end gives undefined, so the sum is NaN from there on. +function pastTheEnd(codes: Uint8Array, bits: number): number { + let key = 3; + for (let i = 0; i <= codes.length; i++) key = trim(key, bits) * 4 + codes[i]!; + return key; +} + +const codes = new Uint8Array(4000); +for (let i = 0; i < codes.length; i++) codes[i] = (i * 2654435761) % 256; +const signed = new Int8Array(3000); +for (let i = 0; i < signed.length; i++) signed[i] = ((i * 40503) % 256) - 128; +const words = new Uint32Array(2000); +for (let i = 0; i < words.length; i++) words[i] = (i * 2246822519) % 4294967296; + +for (const bits of [0, 1, 22, 34, 49, 50, 51, 52, 53]) + console.log(bits, rolling(codes, bits, 3), signedBytes(signed, bits), wideWords(words, bits)); +console.log(pastTheEnd(codes.subarray(0, 10), 20), pastTheEnd(codes.subarray(0, 0), 50)); diff --git a/tests/harness/corpus-costs/speculative-mask-bounds.ts.json b/tests/harness/corpus-costs/speculative-mask-bounds.ts.json new file mode 100644 index 000000000..75ff4f884 --- /dev/null +++ b/tests/harness/corpus-costs/speculative-mask-bounds.ts.json @@ -0,0 +1 @@ +{ "plain": 6700, "san": 5700 } From 7f10ed6d90ee9cd80f979e10878ccc7cc3f541a6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 15:58:15 -0500 Subject: [PATCH 156/193] Use SSE2 and string-instruction memory routines in x86_64 musl executables The musl lane links zig's libc, whose memset, memcmp, bcmp and strlen are byte loops, and whose memcpy and memmove copy without string instructions. An x86_64 musl executable now defines these six routines (scr_musl_mem.h): SSE2 for short and medium lengths, and rep stosb or rep movsb from 2 KiB for memset, memcpy and forward memmove. zig's definitions are weak, so these replace them. Library artifacts keep the routines of the program they link into. Adds a test that checks every length up to 600 and longer ones, every alignment in a 32-byte window, overlapping moves in both directions, memcmp's sign at each position, and strlen up to the end of a readable page, optimized and under ASan and UBSan. --- packages/runtime/src/scr_musl.c | 17 +++ packages/runtime/src/scr_musl_mem.h | 174 ++++++++++++++++++++++ packages/runtime/src/scr_musl_mem.test.c | 147 ++++++++++++++++++ packages/runtime/src/scr_musl_mem.test.ts | 46 ++++++ 4 files changed, 384 insertions(+) create mode 100644 packages/runtime/src/scr_musl_mem.h create mode 100644 packages/runtime/src/scr_musl_mem.test.c create mode 100644 packages/runtime/src/scr_musl_mem.test.ts diff --git a/packages/runtime/src/scr_musl.c b/packages/runtime/src/scr_musl.c index 6de007169..b3f258cf5 100644 --- a/packages/runtime/src/scr_musl.c +++ b/packages/runtime/src/scr_musl.c @@ -55,6 +55,23 @@ void arc4random_buf(void *buf, size_t n) { * CSPRNG shim above and must not acquire context-switching definitions. */ #ifndef SCR_LIB +#ifdef __x86_64__ +/* The libc memory and string routines of an x86_64 executable + * (scr_musl_mem.h). zig's libc defines these as weak symbols, which these + * definitions replace. A library artifact keeps the ones of the program it + * links into. */ +#include "scr_musl_mem.h" +#define SCR_MUSL_LIBC __attribute__((no_builtin)) +SCR_MUSL_LIBC void *memset(void *dst, int c, size_t n) { return scr_musl_memset(dst, c, n); } +SCR_MUSL_LIBC void *memcpy(void *__restrict dst, const void *__restrict src, size_t n) { + return scr_musl_memcpy(dst, src, n); +} +SCR_MUSL_LIBC void *memmove(void *dst, const void *src, size_t n) { return scr_musl_memmove(dst, src, n); } +SCR_MUSL_LIBC int memcmp(const void *a, const void *b, size_t n) { return scr_musl_memcmp(a, b, n); } +SCR_MUSL_LIBC int bcmp(const void *a, const void *b, size_t n) { return scr_musl_memcmp(a, b, n); } +SCR_MUSL_LIBC size_t strlen(const char *s) { return scr_musl_strlen(s); } +#endif + /* musl exposes the POSIX ucontext types but intentionally omits these legacy * functions. scriptc's async/generator fibers need only user-space register * swaps; they never use a context to mutate the process signal mask. These are diff --git a/packages/runtime/src/scr_musl_mem.h b/packages/runtime/src/scr_musl_mem.h new file mode 100644 index 000000000..8cb58de07 --- /dev/null +++ b/packages/runtime/src/scr_musl_mem.h @@ -0,0 +1,174 @@ +/* Memory and string routines for the x86_64 musl lane (scr_musl.c). + * + * The musl lane links zig's libc, whose memset, memcmp, bcmp and strlen are + * byte loops and whose memcpy and memmove use no string instructions. These + * are SSE2 (the x86_64 baseline) for short and medium lengths and + * `rep stosb` / `rep movsb` from SCR_MUSL_MEM_REP bytes, the length from + * which those instructions are fast on CPUs with ERMS. + * + * The functions are prefixed so that the test (scr_musl_mem.test.c) can + * compile them on any x86_64 host; scr_musl.c defines the libc names over + * them. Each is built without builtins: the compiler must not turn their + * loops back into calls to the functions they implement. */ +#ifndef SCR_MUSL_MEM_H +#define SCR_MUSL_MEM_H + +#include +#include +#include + +#define SCR_MUSL_MEM_REP ((size_t)2048) +#define SCR_MUSL_MEM_FN static inline __attribute__((no_builtin, always_inline)) + +typedef uint64_t scr_musl_u64u __attribute__((aligned(1), may_alias)); +typedef uint32_t scr_musl_u32u __attribute__((aligned(1), may_alias)); +typedef uint16_t scr_musl_u16u __attribute__((aligned(1), may_alias)); + +SCR_MUSL_MEM_FN void *scr_musl_memset(void *dst, int c, size_t n) { + unsigned char *d = (unsigned char *)dst; + uint64_t v = 0x0101010101010101ull * (unsigned char)c; + if (n < 16) { + if (n >= 8) { + *(scr_musl_u64u *)d = v; + *(scr_musl_u64u *)(d + n - 8) = v; + } else if (n >= 4) { + *(scr_musl_u32u *)d = (uint32_t)v; + *(scr_musl_u32u *)(d + n - 4) = (uint32_t)v; + } else if (n > 0) { + d[0] = (unsigned char)c; + d[n - 1] = (unsigned char)c; + if (n == 3) d[1] = (unsigned char)c; + } + return dst; + } + if (n >= SCR_MUSL_MEM_REP) { + __asm__ volatile("rep stosb" : "+D"(d), "+c"(n) : "a"(c) : "memory"); + return dst; + } + __m128i x = _mm_set1_epi8((char)c); + _mm_storeu_si128((__m128i *)d, x); + _mm_storeu_si128((__m128i *)(d + n - 16), x); + /* Aligned stores between the two overlapping edge stores. */ + unsigned char *p = (unsigned char *)(((uintptr_t)d + 16) & ~(uintptr_t)15); + for (unsigned char *end = d + n - 16; p < end; p += 16) _mm_store_si128((__m128i *)p, x); + return dst; +} + +/* Up to 32 bytes: every load happens before any store, so any overlap is + * safe (memmove uses this too). */ +SCR_MUSL_MEM_FN void scr_musl_copy_short(unsigned char *d, const unsigned char *s, size_t n) { + if (n >= 16) { + __m128i a = _mm_loadu_si128((const __m128i *)s); + __m128i b = _mm_loadu_si128((const __m128i *)(s + n - 16)); + _mm_storeu_si128((__m128i *)d, a); + _mm_storeu_si128((__m128i *)(d + n - 16), b); + } else if (n >= 8) { + uint64_t a = *(const scr_musl_u64u *)s, b = *(const scr_musl_u64u *)(s + n - 8); + *(scr_musl_u64u *)d = a; + *(scr_musl_u64u *)(d + n - 8) = b; + } else if (n >= 4) { + uint32_t a = *(const scr_musl_u32u *)s, b = *(const scr_musl_u32u *)(s + n - 4); + *(scr_musl_u32u *)d = a; + *(scr_musl_u32u *)(d + n - 4) = b; + } else if (n >= 2) { + uint16_t a = *(const scr_musl_u16u *)s, b = *(const scr_musl_u16u *)(s + n - 2); + *(scr_musl_u16u *)d = a; + *(scr_musl_u16u *)(d + n - 2) = b; + } else if (n == 1) { + d[0] = s[0]; + } +} + +/* More than 32 bytes, forwards: the first and last 16 bytes are loaded + * before the loop and stored after it, and the loop stores to aligned + * destination blocks in between. Safe when the destination does not start + * inside the source (memcpy, and memmove with d before s). */ +SCR_MUSL_MEM_FN void scr_musl_copy_forward(unsigned char *d, const unsigned char *s, size_t n) { + __m128i head = _mm_loadu_si128((const __m128i *)s); + __m128i tail = _mm_loadu_si128((const __m128i *)(s + n - 16)); + for (size_t i = 16 - ((uintptr_t)d & 15); i < n - 16; i += 16) + _mm_store_si128((__m128i *)(d + i), _mm_loadu_si128((const __m128i *)(s + i))); + _mm_storeu_si128((__m128i *)d, head); + _mm_storeu_si128((__m128i *)(d + n - 16), tail); +} + +SCR_MUSL_MEM_FN void *scr_musl_memcpy(void *__restrict dst, const void *__restrict src, size_t n) { + unsigned char *d = (unsigned char *)dst; + const unsigned char *s = (const unsigned char *)src; + if (n <= 32) { + scr_musl_copy_short(d, s, n); + } else if (n >= SCR_MUSL_MEM_REP) { + __asm__ volatile("rep movsb" : "+D"(d), "+S"(s), "+c"(n) : : "memory"); + } else { + scr_musl_copy_forward(d, s, n); + } + return dst; +} + +SCR_MUSL_MEM_FN void *scr_musl_memmove(void *dst, const void *src, size_t n) { + unsigned char *d = (unsigned char *)dst; + const unsigned char *s = (const unsigned char *)src; + if (n <= 32) { + scr_musl_copy_short(d, s, n); + return dst; + } + /* The destination does not start inside the source: copy forwards. */ + if ((uintptr_t)d - (uintptr_t)s >= n) { + if (n >= SCR_MUSL_MEM_REP) { + __asm__ volatile("rep movsb" : "+D"(d), "+S"(s), "+c"(n) : : "memory"); + } else { + scr_musl_copy_forward(d, s, n); + } + return dst; + } + /* The destination starts inside the source: copy backwards, from the + * last aligned destination block down, with the edges loaded first. */ + __m128i head = _mm_loadu_si128((const __m128i *)s); + __m128i tail = _mm_loadu_si128((const __m128i *)(s + n - 16)); + for (ptrdiff_t j = (ptrdiff_t)((((uintptr_t)d + n - 16) & ~(uintptr_t)15) - (uintptr_t)d); j > 0; + j -= 16) + _mm_store_si128((__m128i *)(d + j), _mm_loadu_si128((const __m128i *)(s + j))); + _mm_storeu_si128((__m128i *)(d + n - 16), tail); + _mm_storeu_si128((__m128i *)d, head); + return dst; +} + +SCR_MUSL_MEM_FN int scr_musl_memcmp(const void *left, const void *right, size_t n) { + const unsigned char *a = (const unsigned char *)left, *b = (const unsigned char *)right; + if (n < 16) { + for (size_t i = 0; i < n; i++) + if (a[i] != b[i]) return (int)a[i] - (int)b[i]; + return 0; + } + /* 16-byte blocks, then one last block ending at n (it may overlap the + * previous one, whose bytes are equal). */ + for (size_t i = 0;; i += 16) { + if (i > n - 16) i = n - 16; + __m128i x = _mm_loadu_si128((const __m128i *)(a + i)); + __m128i y = _mm_loadu_si128((const __m128i *)(b + i)); + unsigned diff = (unsigned)_mm_movemask_epi8(_mm_cmpeq_epi8(x, y)) ^ 0xffffu; + if (diff != 0) { + size_t k = i + (size_t)__builtin_ctz(diff); + return (int)a[k] - (int)b[k]; + } + if (i == n - 16) return 0; + } +} + +/* Aligned 16-byte loads never cross a page, so reading a whole block that + * holds the terminator is safe; the bytes before the string are masked off. */ +__attribute__((no_sanitize("address"))) +SCR_MUSL_MEM_FN size_t scr_musl_strlen(const char *str) { + const char *p = (const char *)((uintptr_t)str & ~(uintptr_t)15); + __m128i zero = _mm_setzero_si128(); + unsigned mask = (unsigned)_mm_movemask_epi8(_mm_cmpeq_epi8(_mm_load_si128((const __m128i *)p), zero)); + mask >>= (uintptr_t)str & 15; + if (mask != 0) return (size_t)__builtin_ctz(mask); + for (;;) { + p += 16; + mask = (unsigned)_mm_movemask_epi8(_mm_cmpeq_epi8(_mm_load_si128((const __m128i *)p), zero)); + if (mask != 0) return (size_t)(p - str) + (size_t)__builtin_ctz(mask); + } +} + +#endif /* SCR_MUSL_MEM_H */ diff --git a/packages/runtime/src/scr_musl_mem.test.c b/packages/runtime/src/scr_musl_mem.test.c new file mode 100644 index 000000000..fc956ddfd --- /dev/null +++ b/packages/runtime/src/scr_musl_mem.test.c @@ -0,0 +1,147 @@ +/* The x86_64 musl lane's memory and string routines (scr_musl_mem.h) + * against byte-loop references: every length up to 600 and a set of long + * ones (across the rep threshold), every source and destination alignment + * in a 32-byte window, overlapping moves in both directions, memcmp's sign + * at every position, and strlen up to the end of a readable page. */ +#include "scr_musl_mem.h" + +#include +#include +#include +#include + +/* Checks stay on in optimized builds (NDEBUG). */ +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #cond); \ + abort(); \ + } \ + } while (0) + +static unsigned seed = 12345; +static unsigned char next_byte(void) { + seed = seed * 1103515245u + 12345u; + return (unsigned char)(seed >> 16); +} + +static const size_t long_lengths[] = {1000, 2047, 2048, 2049, 4095, 4096, 5003, 65536, 70001}; + +static void check_lengths(void (*check)(size_t)) { + for (size_t n = 0; n <= 600; n++) check(n); + for (size_t i = 0; i < sizeof long_lengths / sizeof long_lengths[0]; i++) check(long_lengths[i]); +} + +enum { PAD = 64, WINDOW = 32 }; + +static void check_memset(size_t n) { + size_t cap = n + 2 * PAD + WINDOW; + unsigned char *buf = malloc(cap), *ref = malloc(cap); + for (size_t off = 0; off < WINDOW; off += n > 600 ? 7 : 1) { + for (size_t i = 0; i < cap; i++) buf[i] = ref[i] = next_byte(); + int c = next_byte(); + for (size_t i = 0; i < n; i++) ref[PAD + off + i] = (unsigned char)c; + void *r = scr_musl_memset(buf + PAD + off, c, n); + CHECK(r == buf + PAD + off); + for (size_t i = 0; i < cap; i++) CHECK(buf[i] == ref[i]); + } + free(buf); + free(ref); +} + +static void check_memcpy(size_t n) { + size_t cap = n + 2 * PAD + WINDOW; + unsigned char *src = malloc(cap), *dst = malloc(cap), *ref = malloc(cap); + int step = n > 600 ? 5 : 1; + for (size_t so = 0; so < 16; so += step) { + for (size_t doff = 0; doff < 16; doff += step) { + for (size_t i = 0; i < cap; i++) { + src[i] = next_byte(); + dst[i] = ref[i] = next_byte(); + } + for (size_t i = 0; i < n; i++) ref[PAD + doff + i] = src[PAD + so + i]; + void *r = scr_musl_memcpy(dst + PAD + doff, src + PAD + so, n); + CHECK(r == dst + PAD + doff); + for (size_t i = 0; i < cap; i++) CHECK(dst[i] == ref[i]); + } + } + free(src); + free(dst); + free(ref); +} + +/* Overlapping and disjoint moves inside one buffer, in both directions. */ +static void check_memmove(size_t n) { + size_t cap = 2 * n + 2 * PAD + 2 * WINDOW; + unsigned char *buf = malloc(cap), *ref = malloc(cap), *tmp = malloc(n + 1); + static const long shifts[] = {-40, -17, -16, -15, -8, -3, -1, 0, 1, 3, 8, 15, 16, 17, 40}; + for (size_t k = 0; k < sizeof shifts / sizeof shifts[0]; k++) { + for (size_t base = 0; base < 16; base += n > 600 ? 5 : 3) { + long shift = shifts[k]; + size_t s = PAD + WINDOW + base, d = (size_t)((long)s + shift); + for (size_t i = 0; i < cap; i++) buf[i] = ref[i] = next_byte(); + for (size_t i = 0; i < n; i++) tmp[i] = ref[s + i]; + for (size_t i = 0; i < n; i++) ref[d + i] = tmp[i]; + void *r = scr_musl_memmove(buf + d, buf + s, n); + CHECK(r == buf + d); + for (size_t i = 0; i < cap; i++) CHECK(buf[i] == ref[i]); + } + } + free(buf); + free(ref); + free(tmp); +} + +static int sign(int v) { return (v > 0) - (v < 0); } + +static void check_memcmp(size_t n) { + unsigned char *a = malloc(n + 2 * WINDOW), *b = malloc(n + 2 * WINDOW); + for (size_t ao = 0; ao < 4; ao++) { + size_t bo = (ao * 5) % 16; + for (size_t i = 0; i < n; i++) a[ao + i] = b[bo + i] = next_byte(); + CHECK(scr_musl_memcmp(a + ao, b + bo, n) == 0); + size_t positions = n > 600 ? 9 : n; + for (size_t j = 0; j < positions; j++) { + size_t pos = n > 600 ? (n - 1) * j / 8 : j; + unsigned char saved = b[bo + pos]; + unsigned char other = (unsigned char)(saved + 1 + next_byte() % 255); + b[bo + pos] = other; + int want = (int)a[ao + pos] - (int)other; + int got = scr_musl_memcmp(a + ao, b + bo, n); + CHECK(sign(got) == sign(want) && got != 0); + b[bo + pos] = saved; + } + } + free(a); + free(b); +} + +/* Strings ending at every offset before an unreadable page. */ +static void check_strlen(void) { + long page = sysconf(_SC_PAGESIZE); + unsigned char *map = mmap(NULL, (size_t)page * 2, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + CHECK(map != MAP_FAILED); + CHECK(mprotect(map + page, (size_t)page, PROT_NONE) == 0); + for (size_t len = 0; len < 300; len++) { + for (size_t end = 1; end <= 40; end++) { + unsigned char *term = map + page - end; + if ((size_t)(term - map) < len) continue; + unsigned char *start = term - len; + for (unsigned char *p = map; p < map + page; p++) *p = (unsigned char)(1 + next_byte() % 255); + *term = 0; + CHECK(scr_musl_strlen((const char *)start) == len); + } + } + munmap(map, (size_t)page * 2); +} + +int main(void) { + check_lengths(check_memset); + check_lengths(check_memcpy); + check_lengths(check_memmove); + check_lengths(check_memcmp); + check_strlen(); + puts("ok"); + return 0; +} diff --git a/packages/runtime/src/scr_musl_mem.test.ts b/packages/runtime/src/scr_musl_mem.test.ts new file mode 100644 index 000000000..46335acd2 --- /dev/null +++ b/packages/runtime/src/scr_musl_mem.test.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const exec = promisify(execFile); + +/* The x86_64 musl lane's memory and string routines are header functions, + * so the test compiles them with the host's clang on any x86_64 Linux host: + * optimized, and with AddressSanitizer and UndefinedBehaviorSanitizer. */ +const lanes = [ + { name: "optimized", flags: ["-O2"] }, + { name: "sanitized", flags: ["-O1", "-g", "-fsanitize=address,undefined"] }, +]; + +for (const lane of lanes) + test.skipIf(process.platform !== "linux" || process.arch !== "x64")( + `musl lane memset, memcpy, memmove, memcmp and strlen match byte loops (${lane.name})`, + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-musl-mem-")); + try { + const bin = join(dir, "musl-mem"); + await exec("clang", [ + "-std=c11", + "-D_GNU_SOURCE", + "-Wall", + "-Wextra", + "-Werror", + ...lane.flags, + join(import.meta.dirname, "scr_musl_mem.test.c"), + "-o", + bin, + ]); + const result = await exec(bin, [], { + timeout: 120000, + env: { ...process.env, UBSAN_OPTIONS: "halt_on_error=1" }, + }); + expect(result.stdout).toBe("ok\n"); + expect(result.stderr).toBe(""); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); From 0725682c87b2510a1748b4f530efd630eb3068ce Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 15:58:44 -0500 Subject: [PATCH 157/193] Check untyped destructuring sources in JavaScript files before reading keys A JavaScript object pattern over an untyped source (the result of an untyped function, a parameter, a property of an untyped object) now checks the source first when V8 can spell it, as the `any` path in TypeScript files does: a null or undefined source throws "Cannot destructure property 'size' of 'lookup(...)' as it is null." instead of "Cannot read properties of null (reading 'size')". When the first element has a default and a static key (`{ size = 0 }`), the source is not checked first and the read throws, as in V8; this also applies to `any` sources in TypeScript files and to nullable union sources. --- .../src/frontend/lowering/lower-stmts.ts | 80 ++++++++++++++----- .../destructure-untyped-source-null.js.json | 6 ++ .../destructure-nullable-union-source.js | 10 +++ .../corpus/destructure-untyped-source-null.js | 67 ++++++++++++++++ .../destructure-untyped-source-null.js.json | 1 + 5 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-untyped-source-null.js.json create mode 100644 tests/corpus/destructure-untyped-source-null.js create mode 100644 tests/harness/corpus-costs/destructure-untyped-source-null.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 4ff95b6d8..8a209041b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2646,16 +2646,18 @@ export function lowerDestructuringDecl( type: DYN, loc, } - : spelling !== null - ? { - kind: "dynDestrCheck", - value: unitValue(), - spelling, - ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), - type: DYN, - loc, - } - : null; + : firstReadsWithDefault(decl.name as ts.ObjectBindingPattern) + ? firstKeyRead(lowerer, decl.name as ts.ObjectBindingPattern, unitValue, loc) + : spelling !== null + ? { + kind: "dynDestrCheck", + value: unitValue(), + spelling, + ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), + type: DYN, + loc, + } + : null; out.push({ kind: "if", cond: { @@ -2696,22 +2698,25 @@ export function lowerDestructuringDecl( // value: RequireObjectCoercible with V8's own TypeError ("Cannot // destructure property 'a' of 'f(...)' as it is null.") for the source // spellings the assignment form reproduces; other sources are refused, - // as in the assignment form. JavaScript files keep their existing - // per-read errors, except that a runtime computed first key checks the - // source first: Node throws before it evaluates any key, and V8's bare - // message names the source. - const jsRuntimeKeyCheck = + // as in the assignment form. A JavaScript object pattern over a dyn + // source checks the source first in the same way when V8 can spell it, + // so a null or undefined source throws Node's message before any key + // is read or evaluated; other sources keep their per-read errors. V8 + // skips the check when the first element has a default and a static + // key: the read of that key throws ("Cannot read properties of null + // (reading 'a')"), and so does the lowering's. + const jsSourceCheck = init.type.kind === "dyn" && ts.isObjectBindingPattern(decl.name) && isJsSourceFile(ts.getSourceFileOfNode(decl)) && - (decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) && destrSpellingOf(decl.initializer) !== null; if ( - jsRuntimeKeyCheck || - (init.type.kind === "dyn" && - ts.isObjectBindingPattern(decl.name) && - !isJsSourceFile(ts.getSourceFileOfNode(decl)) && - lowerer.anyOrigin(decl.initializer)) + (jsSourceCheck || + (init.type.kind === "dyn" && + ts.isObjectBindingPattern(decl.name) && + !isJsSourceFile(ts.getSourceFileOfNode(decl)) && + lowerer.anyOrigin(decl.initializer))) && + !firstReadsWithDefault(decl.name as ts.ObjectBindingPattern) ) { const spelling = destrSpellingOf(decl.initializer); if (spelling === null) { @@ -9661,6 +9666,39 @@ export function lowerDestructuringAssign( return { kind: "block", body: parts.stmts, loc }; } +/** An object pattern whose first element has a default and a static key + * (`{ a = 1 }`, `{ a: b = 1 }`): V8 reads that key without checking the + * source first, so a null or undefined source throws the read's TypeError. */ +function firstReadsWithDefault(pattern: ts.ObjectBindingPattern): boolean { + const first = pattern.elements.nodes[0]; + return ( + first !== undefined && + first.dotDotDotToken === undefined && + first.initializer !== undefined && + !(first.propertyName !== undefined && ts.isComputedPropertyName(first.propertyName)) + ); +} + +/** The read of the first key of a pattern that `firstReadsWithDefault` + * accepts, over a dyn source: on null or undefined it throws the read's + * TypeError, as V8 does. Null when the key has no static name. */ +function firstKeyRead( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + source: () => IrExpr, + loc: SrcLoc, +): IrExpr | null { + const { firstProp } = firstPatternProp(lowerer, pattern); + if (firstProp === undefined) return null; + return { + kind: "dynKeyGet", + key: { kind: "strLit", value: firstProp, type: STRING, loc }, + value: source(), + type: DYN, + loc, + }; +} + /** The first property V8 names in an object pattern's destructuring * TypeError ("Cannot destructure property 'a' of ..."): an identifier, * string or numeric first key. A computed first key, a rest element or diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-untyped-source-null.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-untyped-source-null.js.json new file mode 100644 index 000000000..dfee18633 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-untyped-source-null.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/destructure-untyped-source-null.js" + ], + "diags": [] +} diff --git a/tests/corpus/destructure-nullable-union-source.js b/tests/corpus/destructure-nullable-union-source.js index b1d9de7b3..245a3474a 100644 --- a/tests/corpus/destructure-nullable-union-source.js +++ b/tests/corpus/destructure-nullable-union-source.js @@ -48,6 +48,16 @@ show("object-undefined", () => { const { name } = items.find((item) => item.size > 5); console.log("never", name); }); +// A default on the first element: V8 reads that key without checking +// the source first, so the read's TypeError is the one thrown. +show("object-first-default", () => { + const { name = "none", size } = items.find((item) => item.size > 5); + console.log("never", name, size); +}); +show("object-later-default", () => { + const { name, size = 0 } = items.find((item) => item.size > 5); + console.log("never", name, size); +}); function firstMatch(text) { return text.match(/q/); } diff --git a/tests/corpus/destructure-untyped-source-null.js b/tests/corpus/destructure-untyped-source-null.js new file mode 100644 index 000000000..0b09c6508 --- /dev/null +++ b/tests/corpus/destructure-untyped-source-null.js @@ -0,0 +1,67 @@ +// A destructuring declaration over an untyped source (the result of an +// untyped function, a parameter, a property of an untyped object) checks +// the source first: a null or undefined source throws Node's TypeError, +// which names the source and the first property, before any key is read. + +function show(label, fn) { + try { + fn(); + } catch (error) { + console.log(label, error instanceof TypeError, error.message); + } +} + +const table = { size: 3, entry: null, nested: { inner: undefined } }; +function lookup(key) { + return table[key]; +} +function read(source) { + const { size, name } = source; + return [size, name]; +} + +show("call-null", () => { + const { size } = lookup("entry"); + console.log("never", size); +}); +show("call-undefined", () => { + const { size, label } = lookup("missing"); + console.log("never", size, label); +}); +show("parameter", () => { + console.log("never", read(null)); +}); +show("property-chain", () => { + const { value } = table.nested.inner; + console.log("never", value); +}); +show("renamed-and-default", () => { + const { size: count = 0, rest } = lookup("entry"); + console.log("never", count, rest); +}); +show("first-default", () => { + const { size = 0 } = lookup("missing"); + console.log("never", size); +}); +show("later-default", () => { + const { size, name = "none" } = lookup("entry"); + console.log("never", size, name); +}); +show("computed-key-default", () => { + const key = "size"; + const { [key]: value = 0 } = lookup("entry"); + console.log("never", value); +}); +show("string-key", () => { + const { "two words": both } = lookup("missing"); + console.log("never", both); +}); +show("rest-first", () => { + const { ...all } = lookup("entry"); + console.log("never", all); +}); + +// Sources that hold a value bind as before, including primitives. +const { size } = lookup("size") === 3 ? table : null; +const { length } = lookup("missing") ?? "text"; +console.log("bound", size, length, read({ size: 1, name: "one" }).join(",")); diff --git a/tests/harness/corpus-costs/destructure-untyped-source-null.js.json b/tests/harness/corpus-costs/destructure-untyped-source-null.js.json new file mode 100644 index 000000000..ef439d096 --- /dev/null +++ b/tests/harness/corpus-costs/destructure-untyped-source-null.js.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4500 } From e3a42c52a81eba1e2de088285416405f9c72a1f7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:00:39 -0500 Subject: [PATCH 158/193] Ignore SIGPIPE and SIGXFSZ in executables, as Node does Node sets SIGPIPE and SIGXFSZ to SIG_IGN at startup, so a write to a closed pipe or past RLIMIT_FSIZE fails with EPIPE or EFBIG, and libuv's spawns start children with every signal at its default action. The runtime instead blocked both signals in the writing thread. On Linux the signal a write raises goes to that thread, but on macOS it goes to the process, so a write to a closed pipe from the fs pool's threads ended the program with SIGPIPE. - scr_init ignores SIGPIPE and SIGXFSZ. A process.on listener replaces the disposition while it is registered, as before. - scr_fs_write_once writes directly in executables, and the fs pool's threads no longer block the two signals. Library builds keep the per-thread blocking, since their host owns the dispositions. - scr_spawn_command starts children with SIGPIPE and SIGXFSZ at their default actions (POSIX_SPAWN_SETSIGDEF), for spawnSync, execFileSync and spawn alike. write-signals-ignored.ts and write-signals-listener.mjs check the ignored signals, a listener, and each spawn path's child against Node. --- .../corpus/write-signals-ignored.ts.json | 6 ++++ .../corpus/write-signals-listener.mjs.json | 6 ++++ packages/runtime/src/scr_async.c | 8 +++-- packages/runtime/src/scr_child.c | 32 ++++++++++++++++--- packages/runtime/src/scr_console.c | 12 +++++++ packages/runtime/src/scr_lib.c | 11 ++++--- tests/corpus/write-signals-ignored.ts | 30 +++++++++++++++++ tests/corpus/write-signals-listener.mjs | 12 +++++++ .../write-signals-ignored.ts.json | 1 + .../write-signals-listener.mjs.json | 1 + tests/harness/windows-differential.test.ts | 3 ++ 11 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json create mode 100644 tests/corpus/write-signals-ignored.ts create mode 100644 tests/corpus/write-signals-listener.mjs create mode 100644 tests/harness/corpus-costs/write-signals-ignored.ts.json create mode 100644 tests/harness/corpus-costs/write-signals-listener.mjs.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json new file mode 100644 index 000000000..7712a33bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/write-signals-ignored.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json new file mode 100644 index 000000000..090e24684 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/write-signals-listener.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index c82cb9052..43b38e77a 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3764,9 +3764,10 @@ static DWORD WINAPI scr_fs_rename_worker(LPVOID payload) { #else static void *scr_fs_rename_worker(void *payload) { (void)payload; - /* A pool thread keeps SIGPIPE and SIGXFSZ blocked: its file writes then - * report EPIPE and EFBIG without changing the signal mask per write - * (scr_fs_write_once). */ +#ifdef SCR_LIB + /* In a library, a pool thread keeps SIGPIPE and SIGXFSZ blocked: its file + * writes then report EPIPE and EFBIG without changing the signal mask per + * write (scr_fs_write_once). Executables ignore both signals (scr_init). */ sigset_t write_set; sigemptyset(&write_set); #ifdef SIGPIPE @@ -3776,6 +3777,7 @@ static void *scr_fs_rename_worker(void *payload) { sigaddset(&write_set, SIGXFSZ); #endif (void)pthread_sigmask(SIG_BLOCK, &write_set, NULL); +#endif scr_fs_rename_worker_loop(); return NULL; } diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 93169be80..324faf6a7 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -3208,10 +3208,9 @@ static void scr_exec_envp_free(char **envp); * next entry. The last such failure is EACCES if any candidate was denied, * else ENOENT. Relative entries are resolved by the child, which starts in * `cwd`. Returns posix_spawn's error number. */ -static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa, - const posix_spawnattr_t *attr, char *const *argv, - char *const *envp, const char *cwd) { - (void)cwd; /* the file actions change into it before the exec */ +static int scr_spawn_resolved(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa, + const posix_spawnattr_t *attr, char *const *argv, + char *const *envp) { if (envp == NULL) return posix_spawnp(pid, file, fa, attr, argv, environ); if (strchr(file, '/')) return posix_spawn(pid, file, fa, attr, argv, envp); const char *path = "/usr/bin:/bin"; @@ -3243,6 +3242,31 @@ static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_fil return denied ? EACCES : ENOENT; } +/* scr_spawn_resolved with the child's SIGPIPE and SIGXFSZ at their default + * actions, as libuv's spawns start them: executables ignore both + * (scr_init), and an exec keeps ignored dispositions. `attr` may be NULL. */ +static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa, + posix_spawnattr_t *attr, char *const *argv, char *const *envp, + const char *cwd) { + (void)cwd; /* the file actions change into it before the exec */ + posix_spawnattr_t local; + if (attr == NULL) { + posix_spawnattr_init(&local); + attr = &local; + } + sigset_t defaults; + sigemptyset(&defaults); + sigaddset(&defaults, SIGPIPE); + sigaddset(&defaults, SIGXFSZ); + short flags = 0; + (void)posix_spawnattr_getflags(attr, &flags); + (void)posix_spawnattr_setsigdefault(attr, &defaults); + (void)posix_spawnattr_setflags(attr, (short)(flags | POSIX_SPAWN_SETSIGDEF)); + int err = scr_spawn_resolved(pid, file, fa, attr, argv, envp); + if (attr == &local) posix_spawnattr_destroy(&local); + return err; +} + /* env_pairs: NULL inherits the parent's environment; else [k,v,...] * replaces it, as Node's `env` option does, and the command is looked up * on the replacement's PATH (scr_spawn_command). */ diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index d28f058fa..0941db370 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -5,6 +5,9 @@ #include #include #include +#if !defined(_WIN32) && !defined(__wasi__) +#include +#endif #ifdef _WIN32 #include /* _O_BINARY */ #include /* _setmode, _fileno */ @@ -179,6 +182,15 @@ void scr_init(void) { * stream immediately. */ static char outbuf[1 << 16]; setvbuf(stdout, outbuf, _IOFBF, sizeof outbuf); +#if !defined(_WIN32) && !defined(__wasi__) + /* Node ignores SIGPIPE and SIGXFSZ: a write to a closed pipe or past + * RLIMIT_FSIZE fails with EPIPE or EFBIG, on any thread, instead of + * ending the process. A process.on listener replaces the disposition + * while it is registered (scr_events.c), and spawned children start with + * both at their default actions (scr_spawn_command). */ + signal(SIGPIPE, SIG_IGN); + signal(SIGXFSZ, SIG_IGN); +#endif #ifdef SCR_WORKERS } #endif diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 3bb9a0725..73ff388a9 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4136,13 +4136,14 @@ static bool scr_fs_write_fd_valid(double fd) { /* write(2) raises SIGPIPE before returning EPIPE when a pipe/socket peer has * closed; write(2)/pwrite(2) likewise raise SIGXFSZ before returning EFBIG at - * RLIMIT_FSIZE. Node turns both into catchable fs errors. Do the same per - * calling thread rather than changing process dispositions (which spawned - * children would inherit). Consume only a signal freshly generated by this - * call, preserving any signal that was already pending. */ + * RLIMIT_FSIZE. Node ignores both signals, so they arrive as catchable fs + * errors. Executables ignore them too (scr_init) and write directly. A + * library does not own its host's dispositions: it blocks the signals in + * the calling thread instead, and consumes only a signal freshly generated + * by this call, preserving any signal that was already pending. */ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, bool positioned, double position) { -#if defined(_WIN32) || defined(__wasi__) +#if defined(_WIN32) || defined(__wasi__) || !defined(SCR_LIB) return positioned ? scr_fs_pwrite(fd, data, length, position) : write(fd, data, length); diff --git a/tests/corpus/write-signals-ignored.ts b/tests/corpus/write-signals-ignored.ts new file mode 100644 index 000000000..565ffc061 --- /dev/null +++ b/tests/corpus/write-signals-ignored.ts @@ -0,0 +1,30 @@ +// Node ignores SIGPIPE and SIGXFSZ: the process keeps running when either +// arrives, and spawned children start with both at their default actions, +// through each spawn path. write-signals-listener.mjs covers a listener. +import { execFileSync, spawn, spawnSync } from "node:child_process"; + +process.kill(process.pid, "SIGPIPE"); +process.kill(process.pid, "SIGXFSZ"); +console.log("ignored"); + +for (const name of ["PIPE", "XFSZ"]) { + const child = spawnSync("/bin/sh", ["-c", `kill -${name} $$; echo survived`], { + encoding: "utf8", + }); + console.log("spawnSync", name, child.status, child.signal, JSON.stringify(child.stdout)); +} + +try { + execFileSync("/bin/sh", ["-c", "kill -PIPE $$; echo survived"], { encoding: "utf8" }); + console.log("execFileSync survived"); +} catch (error) { + console.log("execFileSync", (error as { signal?: string }).signal); +} + +const child = spawn("/bin/sh", ["-c", "kill -XFSZ $$; echo survived"]); +let output = ""; +child.stdout!.setEncoding("utf8"); +child.stdout!.on("data", (chunk: string) => (output += chunk)); +child.on("exit", (code, signal) => { + console.log("spawn", code, signal, JSON.stringify(output)); +}); diff --git a/tests/corpus/write-signals-listener.mjs b/tests/corpus/write-signals-listener.mjs new file mode 100644 index 000000000..da43dfa83 --- /dev/null +++ b/tests/corpus/write-signals-listener.mjs @@ -0,0 +1,12 @@ +// Node ignores SIGPIPE and SIGXFSZ until a listener is registered: the +// listener then receives each signal, and the process keeps running. +const keepAlive = setInterval(() => {}, 1000); +let received = 0; +for (const signal of ["SIGPIPE", "SIGXFSZ"]) { + process.on(signal, (name) => { + console.log("listener", name); + if (++received === 2) clearInterval(keepAlive); + }); +} +process.kill(process.pid, "SIGPIPE"); +setTimeout(() => process.kill(process.pid, "SIGXFSZ"), 20); diff --git a/tests/harness/corpus-costs/write-signals-ignored.ts.json b/tests/harness/corpus-costs/write-signals-ignored.ts.json new file mode 100644 index 000000000..4f7328ed7 --- /dev/null +++ b/tests/harness/corpus-costs/write-signals-ignored.ts.json @@ -0,0 +1 @@ +{ "plain": 4490, "san": 66079 } diff --git a/tests/harness/corpus-costs/write-signals-listener.mjs.json b/tests/harness/corpus-costs/write-signals-listener.mjs.json new file mode 100644 index 000000000..b73cebb1b --- /dev/null +++ b/tests/harness/corpus-costs/write-signals-listener.mjs.json @@ -0,0 +1 @@ +{ "plain": 3437, "san": 6636 } diff --git a/tests/harness/windows-differential.test.ts b/tests/harness/windows-differential.test.ts index 76293fc5b..752b505b3 100644 --- a/tests/harness/windows-differential.test.ts +++ b/tests/harness/windows-differential.test.ts @@ -101,6 +101,9 @@ const WINDOWS_SKIPS: Record = { // spawns succeed, exposed here where every one fails. "process-named-signals.cjs": "POSIX signal delivery: Windows Node cannot send SIGWINCH or register SIGSTOP", + "write-signals-ignored.ts": + "POSIX signals: Windows has no SIGPIPE or SIGXFSZ, and the children are /bin/sh", + "write-signals-listener.mjs": "POSIX signals: Windows has no SIGPIPE or SIGXFSZ", "1360-spawn-sync.ts": 'posix-shaped: every spawn is ENOENT on Windows Node too, exposing the documented spawn-failure ""-vs-null stdout stance (1644 covers spawnSync here)', "1361-spawn-events.ts": From fb9499c846ad54975466f6dbbe816c122ff4bbd4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:11:17 -0500 Subject: [PATCH 159/193] Keep timer deadlines on macOS and re-arm intervals as Node does The event loop slept to its next timer with nanosleep. macOS applies the timer slack of the thread's QoS class to nanosleep: a 5 ms sleep takes about 38 ms at utility QoS and 180 ms at background QoS, where launchd services such as CI jobs run. Timers then fired out of order against Node, whose libuv loop waits with a kevent timeout that keeps its deadline at every QoS. On macOS the loop now sleeps with a kevent timeout on a kqueue with no events, shared by every thread's loop. Intervals also re-arm from the time their callback started, as Node's listOnTimeout does, instead of from the clock after the callback: a callback that takes 30 ms of a 50 ms interval keeps the ticks 50 ms apart, and one slower than the interval makes the next tick due on the next turn. interval-slow-callback.ts checks both against Node. --- .../corpus/interval-slow-callback.ts.json | 6 ++ packages/runtime/src/scr_async.c | 58 ++++++++++++++++--- tests/corpus/interval-slow-callback.ts | 23 ++++++++ .../interval-slow-callback.ts.json | 1 + 4 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json create mode 100644 tests/corpus/interval-slow-callback.ts create mode 100644 tests/harness/corpus-costs/interval-slow-callback.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json new file mode 100644 index 000000000..9e7ab676c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/interval-slow-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/interval-slow-callback.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 43b38e77a..5647a596f 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -58,6 +58,9 @@ #include #include #include +#ifdef __APPLE__ +#include +#endif #endif #if defined(__has_feature) @@ -1052,6 +1055,44 @@ double scr_now_ms(void) { return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6; } +/* The loop's sleep to its next timer when nothing else can wake it. macOS + * gives nanosleep the timer slack of the thread's QoS class: a 5 ms sleep + * takes about 38 ms at utility QoS and 180 ms at background QoS, which is + * where launchd services, CI jobs among them, run. A kevent timeout keeps + * its deadline at every QoS, as libuv's loop does. */ +#if defined(__APPLE__) +/* One queue with no events for every thread that sleeps (worker loops + * included), created on first use. */ +static _Atomic int scr_sleep_kq = -1; +#endif +static void scr_loop_sleep(double wait_ms) { + struct timespec ts = {(time_t)(wait_ms / 1000.0), + (long)((wait_ms - (double)((time_t)(wait_ms / 1000.0)) * 1000.0) * 1e6)}; +#if defined(__APPLE__) + int kq = atomic_load_explicit(&scr_sleep_kq, memory_order_acquire); + if (kq < 0) { + int created = kqueue(); + if (created >= 0) { + (void)fcntl(created, F_SETFD, FD_CLOEXEC); + int none = -1; + if (atomic_compare_exchange_strong(&scr_sleep_kq, &none, created)) { + kq = created; + } else { + close(created); + kq = none; + } + } + } + if (kq >= 0) { + /* An interrupted wait returns early, as nanosleep does. */ + struct kevent ignored; + (void)kevent(kq, NULL, 0, &ignored, 1, &ts); + return; + } +#endif + nanosleep(&ts, NULL); +} + static bool scr_timer_before(const ScrTimer *a, const ScrTimer *b) { if (a->deadline_ms != b->deadline_ms) return a->deadline_ms < b->deadline_ms; return a->seq < b->seq; @@ -5962,9 +6003,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { } else #endif if (due > now) { - double wait = due - now; - struct timespec ts = {(time_t)(wait / 1000.0), (long)((wait - (double)((time_t)(wait / 1000.0)) * 1000.0) * 1e6)}; - nanosleep(&ts, NULL); + scr_loop_sleep(due - now); now = due; } } @@ -5986,13 +6025,16 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { scr_firing_reffed = t.reffed; /* unref/ref during the callback lands here */ scr_firing_refresh = false; } + double started = t.id != 0 && t.repeat_ms > 0 ? scr_now_ms() : 0; ((void (*)(ScrClosure *))t.cb->fn)(t.cb); if (t.id != 0 && t.repeat_ms > 0 && !scr_firing_cleared) { - /* Re-arm relative to the post-callback clock (libuv's uv_timer - * repeat behavior: no catch-up bursts after a slow callback). The - * ref state carries across ticks (a self-unref'd interval stays - * unref'd). */ - ScrTimer again = {scr_now_ms() + t.repeat_ms, scr_timer_seq++, t.cb, t.repeat_ms, t.id, scr_firing_reffed, t.delay_ms}; + /* Re-arm relative to the time the callback started, as Node's + * listOnTimeout does (getLibuvNow() before _onTimeout): a slow + * callback does not push the next tick back by its own duration. + * One slower than the interval makes the next tick due on the next + * turn, without catch-up bursts. The ref state carries across + * ticks (a self-unref'd interval stays unref'd). */ + ScrTimer again = {started + t.repeat_ms, scr_timer_seq++, t.cb, t.repeat_ms, t.id, scr_firing_reffed, t.delay_ms}; scr_timer_push(again); } else if (t.id != 0 && scr_firing_refresh && !scr_firing_cleared) { /* refresh() from inside the one-shot's own callback: re-arm to diff --git a/tests/corpus/interval-slow-callback.ts b/tests/corpus/interval-slow-callback.ts new file mode 100644 index 000000000..01f0f21d1 --- /dev/null +++ b/tests/corpus/interval-slow-callback.ts @@ -0,0 +1,23 @@ +// setInterval schedules each tick from the time its callback started, as +// Node does: a callback that takes 30 ms of a 50 ms interval keeps the ticks +// 50 ms apart, and one that takes 70 ms makes the next tick due at once (70 +// ms apart, not 120). Each line prints whether every gap after the first +// tick stays under a bound between those spacings and the ones a re-arm +// after the callback would give (80 and 120 ms). +function run(work: number, bound: number, done: () => void): void { + const gaps: number[] = []; + let last = -1; + const interval = setInterval(() => { + const now = performance.now(); + if (last >= 0) gaps.push(now - last); + last = now; + while (performance.now() - now < work) {} + if (gaps.length === 4) { + clearInterval(interval); + console.log(`work ${work} ms:`, gaps.every((gap) => gap < bound)); + done(); + } + }, 50); +} + +run(30, 75, () => run(70, 110, () => console.log("done"))); diff --git a/tests/harness/corpus-costs/interval-slow-callback.ts.json b/tests/harness/corpus-costs/interval-slow-callback.ts.json new file mode 100644 index 000000000..0a62b4037 --- /dev/null +++ b/tests/harness/corpus-costs/interval-slow-callback.ts.json @@ -0,0 +1 @@ +{ "plain": 5676, "san": 6800 } From 2cbdc9207fb2eb100e8d21b12a289de3cccbb164 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:20:02 -0500 Subject: [PATCH 160/193] Read and write record fields through keys whose literal type names fields of one type --- .../lowering/expressions/element-access.ts | 80 ++++++++++++++++++- .../expressions/keyed-field-targets.ts | 29 +++++++ .../corpus/record-literal-key-unions.ts.json | 6 ++ tests/corpus/record-literal-key-unions.ts | 78 ++++++++++++++++++ .../record-literal-key-unions.ts.json | 1 + 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json create mode 100644 tests/corpus/record-literal-key-unions.ts create mode 100644 tests/harness/corpus-costs/record-literal-key-unions.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 1ce873853..85be6bb53 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -84,7 +84,13 @@ import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; import { withInheritedMethods } from "../record-inherited.js"; import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; import { isClassCallback } from "../class-callbacks.js"; -import { isKeyedFieldAccess } from "./keyed-field-targets.js"; +import { + isKeyedFieldAccess, + keyedFieldRead, + keyedFieldWrite, + sameTypedFieldKeys, + type KeyedFieldTarget, +} from "./keyed-field-targets.js"; import { lowerFieldCompoundValue } from "./updates.js"; import { readsHeldEvolvingArray } from "../lower-stmts.js"; import { @@ -1548,6 +1554,36 @@ export function lowerRecordKeyRead( recordKeyResultOk(lowerer, shape, DYN) ) declared = DYN; + // A key whose literal type names only declared fields of one type, on a + // record whose other fields have other types, reads the field it names. + // A called access (`r[key]()`) keeps the refusal. + const named = + !declared && litKey === null && !isCalledAccess(expr) + ? sameTypedFieldKeys(lowerer, shape, keyNode) + : null; + if (named) { + const stmts: IrStmt[] = []; + const settle = (value: IrExpr, name: string, force = false): IrExpr => { + if (!force && isSafeToRepeat(value)) return value; + const temp = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + return varRef(temp.id, temp.type, loc); + }; + const receiver = settle(obj, "%keyedReceiver", !isSafeToDiscard(key)); + const target: KeyedFieldTarget = { + container: "recordKeyed", + obj: receiver, + shapeId, + field: named.keys[0]!, + keys: named.keys, + key: settle(key, "%keyedKey"), + fieldType: named.fieldType, + }; + const get = keyedFieldRead(lowerer, target, loc, true); + const read: IrExpr = + stmts.length === 0 ? get : { kind: "seqExpr", stmts, result: get, type: get.type, loc }; + return includeUndefined ? read : lowerer.maybeNarrow(read, expr); + } if (!declared) { lowerer.unsupported( "SC1090", @@ -2571,6 +2607,48 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): }; } } + // A key whose literal type names only declared fields of one type + // writes the field it names. JavaScript evaluates the receiver, then + // the key, then the value; the dispatch reads the saved receiver and + // key once per named field. + const named = + litKey === null ? sameTypedFieldKeys(lowerer, shape, target.argumentExpression) : null; + if (named && !shape.fields.every((f) => typeEquals(f.type, named.fieldType))) { + const loc = locOf(expr); + const body: IrStmt[] = []; + const save = (value: IrExpr, name: string): IrExpr => { + if (value.kind === "varRef") return value; + const temp = lowerer.declareHiddenLocal(name, value.type); + body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + return varRef(temp.id, temp.type, loc); + }; + const obj = save(lowerer.lowerExpr(target.expression), "%keyedReceiver"); + const rawKey = lowerRecordPropertyKey( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + target.argumentExpression, + ); + if (rawKey.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing records with non-string or non-number keys", + ); + } + const key = save(rawKey, "%keyedKey"); + const value = lowerer.lowerExprExpecting(expr.right, named.fieldType); + const keyed: KeyedFieldTarget = { + container: "recordKeyed", + obj, + shapeId: receiverIr.shapeId, + field: named.keys[0]!, + keys: named.keys, + key, + fieldType: named.fieldType, + }; + body.push(keyedFieldWrite(lowerer, keyed, value, loc)); + return { kind: "block", body, loc }; + } if (!shape.indexValue) { // A SIGNATURE-FREE shape whose declared fields share ONE type // writes through the same per-shape dispatch (the mockable-clock diff --git a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts index 932e2e4b6..267628572 100644 --- a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts +++ b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts @@ -5,9 +5,12 @@ import { DYN, F64, STRING, + shapeHasAccessorSlots, typeEquals, type IrExpr, + type IrRecordShape, type IrStmt, + type IrType, type SrcLoc, } from "../../../ir/ir.js"; import { isJsSourceFile } from "../../program.js"; @@ -141,6 +144,32 @@ export function keyedFieldTarget( }; } +/** The declared fields a computed key names when the key's literal type + * allows only names of declared fields stored with one type + * (`stats[counter]` with `counter: "hits" | "misses"` on a record whose + * other fields have other types). Null for any other key or shape: + * accessor-carrying shapes, index-signature shapes and JavaScript sources + * keep their own keyed access. */ +export function sameTypedFieldKeys( + lowerer: Lowerer, + shape: IrRecordShape, + keyNode: ts.Expression, +): { keys: string[]; fieldType: IrType } | null { + if ( + shape.tuple || + shape.indexValue !== undefined || + shapeHasAccessorSlots(shape) || + isJsSourceFile(ts.getSourceFileOfNode(keyNode)) + ) + return null; + const keys = literalFieldKeys(lowerer, keyNode); + if (!keys) return null; + const fields = keys.map((k) => shape.fields.find((f) => f.name === k)); + const first = fields[0]; + if (!first || fields.some((f) => !f || !typeEquals(f.type, first.type))) return null; + return { keys, fieldType: first.type }; +} + /** Does `key` name `field`? */ function keyIs(key: IrExpr, field: string, loc: SrcLoc): IrExpr { return { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json new file mode 100644 index 000000000..b10d37dd3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-literal-key-unions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-literal-key-unions.ts b/tests/corpus/record-literal-key-unions.ts new file mode 100644 index 000000000..afe328e98 --- /dev/null +++ b/tests/corpus/record-literal-key-unions.ts @@ -0,0 +1,78 @@ +// Keyed reads and writes whose key is a union of literal names of declared +// fields stored with one type, on records whose other fields have other +// types: plain reads, plain writes, compound updates, keys from parameters +// and loops, and JavaScript evaluation order with effectful receivers and +// keys. +type Stats = { hits: number; misses: number; bytes: number; name: string; ok: boolean }; +type Counter = "hits" | "misses" | "bytes"; +type Flags = { name: string; verbose: boolean; strict: boolean; level: number }; +type Labels = { id: number; title: string; summary: string; tags: string[] }; + +function read(s: Stats, key: Counter): number { + return s[key]; +} + +function bump(s: Stats, key: Counter, by: number): void { + s[key] = s[key] + by; +} + +function toggle(flags: Flags, key: "verbose" | "strict"): boolean { + flags[key] = !flags[key]; + return flags[key]; +} + +function relabel(labels: Labels, key: "title" | "summary", text: string): string { + const before = labels[key]; + labels[key] = text.toUpperCase(); + return `${before} -> ${labels[key]}`; +} + +const stats: Stats[] = []; +for (let i = 0; i < 4; i++) stats.push({ hits: 0, misses: 0, bytes: 0, name: "s" + i, ok: true }); + +let seed = 7; +for (let i = 0; i < 1000; i++) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + const s = stats[seed & 3]!; + bump(s, seed % 5 === 0 ? "misses" : "hits", 1); + bump(s, "bytes", (seed >>> 8) & 255); +} +for (const s of stats) console.log(s.name, read(s, "hits"), read(s, "misses"), read(s, "bytes"), s.ok); + +// Keys from a loop over a constant list of names. +const counters = ["hits", "misses", "bytes"] as const; +const totals: Stats = { hits: 0, misses: 0, bytes: 0, name: "total", ok: false }; +for (const s of stats) for (const key of counters) totals[key] += s[key]; +console.log(totals.name, totals.hits, totals.misses, totals.bytes); + +// Booleans and strings. +const flags: Flags = { name: "build", verbose: false, strict: true, level: 2 }; +console.log(toggle(flags, "verbose"), toggle(flags, "strict"), toggle(flags, "verbose"), flags.level); +const labels: Labels = { id: 1, title: "draft", summary: "none", tags: ["a"] }; +console.log(relabel(labels, "title", "final"), relabel(labels, "summary", "short"), labels.tags.length); + +// The receiver, then the key, then the value, each once. +const order: string[] = []; +function pick(i: number): Stats { + order.push("receiver " + i); + return stats[i]!; +} +function name(key: Counter): Counter { + order.push("key " + key); + return key; +} +function amount(n: number): number { + order.push("value " + n); + return n; +} +pick(1)[name("misses")] = amount(40); +console.log(order.join(", "), stats[1]!.misses); +order.length = 0; +console.log(pick(2)[name("bytes")], order.join(", ")); + +// A key that names one field. +function only(s: Stats, key: "bytes"): number { + s[key] = s[key] * 2; + return s[key]; +} +console.log(only(stats[3]!, "bytes")); diff --git a/tests/harness/corpus-costs/record-literal-key-unions.ts.json b/tests/harness/corpus-costs/record-literal-key-unions.ts.json new file mode 100644 index 000000000..719a1099c --- /dev/null +++ b/tests/harness/corpus-costs/record-literal-key-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 5800, "san": 62700 } From 5a48c8f1a2eacc304208008513d67251d96d708d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:31:50 -0500 Subject: [PATCH 161/193] Infer types for the program's own JavaScript behind SCRIPTC_INFER_JS With SCRIPTC_INFER_JS=1, shape inference writes its overlay for the program's own JavaScript files, with or without npm-static packages, and leaves package files as they are. The analysis and overlay keep a value untyped where an inferred type would be narrower than what the program holds: awaited values of for await, iterable object literals with computed keys, matchAll and other builtin results, fields an instance may lack, overridden methods and getters, class instances reached by reflection, constructor parameters stored into wider fields, and results the analysis cannot type. A computed member name no longer folds a read that runs before its binding is initialized. In every build, a JavaScript binding written with a value that may be a string, or with a union, keeps the dynamic representation. --- .../compiler/src/frontend/js-inference.ts | 18 +++ .../src/frontend/lowering/lower-stmts.ts | 18 ++- packages/compiler/src/frontend/program.ts | 6 +- .../src/frontend/shape-inference/analyze.ts | 79 ++++++++++-- .../src/frontend/shape-inference/domain.ts | 1 - .../src/frontend/shape-inference/index.ts | 5 +- .../src/frontend/shape-inference/overlay.ts | 116 +++++++++++++++++- .../shape-inference/static-objects.ts | 17 ++- .../inferred-builtin-method-results.js.json | 6 + .../corpus/js-binding-union-writes.js.json | 6 + .../corpus/inferred-builtin-method-results.js | 21 ++++ tests/corpus/js-binding-union-writes.js | 15 +++ tests/harness/js-inference.test.ts | 95 ++++++++++++++ 13 files changed, 382 insertions(+), 21 deletions(-) create mode 100644 packages/compiler/src/frontend/js-inference.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json create mode 100644 tests/corpus/inferred-builtin-method-results.js create mode 100644 tests/corpus/js-binding-union-writes.js create mode 100644 tests/harness/js-inference.test.ts diff --git a/packages/compiler/src/frontend/js-inference.ts b/packages/compiler/src/frontend/js-inference.ts new file mode 100644 index 000000000..138518f02 --- /dev/null +++ b/packages/compiler/src/frontend/js-inference.ts @@ -0,0 +1,18 @@ +/* Shape inference for the program's own JavaScript (SCRIPTC_INFER_JS=1). + * + * Shape inference (shape-inference/) proves value sets with a closed-world + * points-to analysis and writes them as JSDoc into overlay copies of the + * files, which the checker then types. By default it runs only for builds + * with --npm-static packages, behind SCRIPTC_SHAPE_OVERLAY=1. This option + * runs it for the program's own JavaScript files, with or without + * npm-static packages, and leaves the packages' files as they are unless + * SCRIPTC_SHAPE_OVERLAY=1 is set too (SCRIPTC_SHAPE_OVERLAY=0 turns it + * off). The overlay's fallback applies unchanged: a refusal, or a deferred + * refusal the build without the overlay lacks, drops the annotations it + * depends on, and the program compiles as it does without the overlay where + * it must. */ + +/** Whether the option is on. */ +export function jsInferenceEnabled(): boolean { + return process.env["SCRIPTC_INFER_JS"] === "1"; +} diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 8231365b1..2f32a9263 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -7329,13 +7329,18 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar ) ) return true; - // A `+=` of a string turns a number or boolean binding into a string - // (`n += "px"`); the checker keeps the declared type. + // A `+=` of a string, or of a value that may be one, turns a number or + // boolean binding into a string (`n += "px"`); the checker keeps the + // declared type. + const mayBeString = (type: ts.Type): boolean => + type.flags & ts.TypeFlags.Union + ? constituentTypes(type).some(mayBeString) + : (type.flags & ts.TypeFlags.StringLike) !== 0; if ( candidates.some( (node) => node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken && - (lowerer.typeOf(node.right).flags & ts.TypeFlags.StringLike) !== 0, + mayBeString(lowerer.typeOf(node.right)), ) ) { const declared = lowerer.mapTypeOf(lowerer.typeOf(decl.name)); @@ -7372,6 +7377,13 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar if (declared === null || declared.kind === "dyn" || declared.kind === "union") return false; return differing.some((node) => { const written = lowerer.mapTypeOf(lowerer.typeOf(node.right)); + // A union a primitive binding cannot hold (`x = c ? 1 : "a"` into + // `var x = 0`). + if ( + written?.kind === "union" && + (declared.kind === "f64" || declared.kind === "bool" || declared.kind === "string") + ) + return true; return ( written !== null && written.kind !== "dyn" && diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index bcd5e44dc..ae600d483 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -161,6 +161,7 @@ import { captureShapeOverlayState, type ShapeOverlayLoadState, } from "./shape-inference/index.js"; +import { jsInferenceEnabled } from "./js-inference.js"; import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js"; import { containsJsx, @@ -1401,7 +1402,10 @@ export function loadProgram( externalTypes, npmStaticPackages.length > 0, projector, - npmStaticPackages.length > 0 && shapeOverlayEnabled() ? shapeOverlayer : undefined, + (npmStaticPackages.length > 0 && shapeOverlayEnabled()) || + (jsInferenceEnabled() && process.env["SCRIPTC_SHAPE_OVERLAY"] !== "0") + ? shapeOverlayer + : undefined, ); // Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES). const shapes = process.env["SCRIPTC_DEBUG_SHAPES"]; diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts index 670d4a2f8..25d78ce76 100644 --- a/packages/compiler/src/frontend/shape-inference/analyze.ts +++ b/packages/compiler/src/frontend/shape-inference/analyze.ts @@ -338,6 +338,16 @@ function propertyNameText(name: Node): string | null { return null; } +/** Whether an object literal has a spread or a key not known statically. */ +function hasUnknownKeys(node: Node): boolean { + if (!ts.isObjectLiteralExpression(node)) return false; + return list(node.properties).some((p) => { + if (ts.isSpreadAssignment(p)) return true; + const name = (p as { name?: Node }).name; + return name !== undefined && propertyNameText(name) === null; + }); +} + const shortFiles = new Map(); function shortFile(fileName: string): string { @@ -943,7 +953,11 @@ class Analyzer { this.escapedFunctionCalls.add(info); this.activate(info); for (const p of info.params) this.add(p, this.top); - if (!info.isArrow && info.ownerClass === null) this.add(info.thisNode, this.top); + // Unknown code can call a class's method with any receiver too (through + // `Reflect.get` with a proxy as the receiver); in the program's own + // files its receiver is ⊤. + if (!info.isArrow && (info.ownerClass === null || !isPackageJsFile(info.node.getSourceFile()))) + this.add(info.thisNode, this.top); this.sink(info.ret, reason); } @@ -1065,6 +1079,35 @@ class Analyzer { return out; } + /** Whether every instance of a class has the own field `name`: a class + * of its chain declares it, or a constructor stores it in a statement + * that runs whenever the constructor completes. */ + fieldSurelyPresent(decl: Node, name: string): boolean { + for (const c of this.classChain(decl)) { + for (const m of list((c as ts.ClassLikeDeclaration).members)) { + if (isStatic(m)) continue; + const mn = (m as { name?: Node }).name; + if (ts.isPropertyDeclaration(m) && mn !== undefined && propertyNameText(mn) === name) + return true; + if (!ts.isConstructorDeclaration(m) || m.body === undefined) continue; + for (const st of list(m.body.statements)) { + if (ts.isVariableStatement(st)) continue; + if (!ts.isExpressionStatement(st)) break; + const e = st.expression; + if ( + ts.isBinaryExpression(e) && + e.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(e.left) && + e.left.expression.kind === ts.SyntaxKind.ThisKeyword && + e.left.name.text === name + ) + return true; + } + } + } + return false; + } + lookupMember( decl: Node, name: string, @@ -1719,7 +1762,12 @@ class Analyzer { const src = this.expr(s.expression); const target = this.newNode(); if (ts.isForInStatement(s)) this.add(target, this.prim("string")); - else this.iterateInto(src, target, s); + else if (s.awaitModifier !== undefined) { + // `for await` awaits each value a sync iterable yields. + const yielded = this.newNode(); + this.iterateInto(src, yielded, s); + this.awaitInto(yielded, target); + } else this.iterateInto(src, target, s); const init = s.initializer; if (ts.isVariableDeclarationList(init)) { for (const d of list(init.declarations)) { @@ -1836,9 +1884,13 @@ class Analyzer { this.on(src, (a) => { if (a.kind === "array" || a.kind === "collection") this.edge(this.element(a), dst); else if (a.kind === "prim" && a.label === "string") this.add(dst, this.prim("string")); - else if (a.kind === "prim" || a.kind === "object" || a.kind === "dictionary") { - /* not iterable (a TypeError): no value. Literals with a - Symbol.iterator member have a computed key; they are G sites. */ + else if (a.kind === "object" && a.node !== undefined && hasUnknownKeys(a.node)) { + // An object literal with a computed key or a spread may have a + // Symbol.iterator or Symbol.asyncIterator member. + this.add(dst, this.top); + this.escape(a, `iterated at ${this.loc(site)}`); + } else if (a.kind === "prim" || a.kind === "object" || a.kind === "dictionary") { + /* not iterable (a TypeError): no value. */ } else { this.add(dst, this.top); if (a.kind !== "top") this.escape(a, `iterated at ${this.loc(site)}`); @@ -2712,6 +2764,15 @@ class Analyzer { } if (this.classChain(decl).some((c) => this.baseClass(c) === undefined)) this.add(dst, this.top); + // A field the instance may lack reads undefined (`Late` sets `x` + // only on one branch). In the program's own files only. + if ( + member === null && + !OBJECT_PROTOTYPE_MEMBERS.has(name) && + !isPackageJsFile(decl.getSourceFile()) && + !this.fieldSurelyPresent(decl, name) + ) + this.add(dst, this.prim("undefined")); this.edge(this.field(a, name), dst); this.unknownKeysInto(a, dst); return; @@ -3467,8 +3528,12 @@ class Analyzer { this.add(this.element(out), this.prim("undefined")); this.add(dst, out); this.add(dst, this.prim("null")); - } else if (r !== undefined) prim(r); - else if (method === "replace" || method === "replaceAll") prim("string"); + } else if (method === "valueOf") prim(path.slice(0, path.indexOf("."))); + else if (r !== undefined) { + prim(r); + // An index out of range reads undefined. + if (method === "at" || method === "codePointAt") prim("undefined"); + } else if (method === "replace" || method === "replaceAll") prim("string"); else escapeAll(); if (method === "replace" || method === "replaceAll") callback(1, [this.constNode(this.prim("string"))], null); diff --git a/packages/compiler/src/frontend/shape-inference/domain.ts b/packages/compiler/src/frontend/shape-inference/domain.ts index 21e52346c..69555aef5 100644 --- a/packages/compiler/src/frontend/shape-inference/domain.ts +++ b/packages/compiler/src/frontend/shape-inference/domain.ts @@ -127,7 +127,6 @@ export const STRING_METHOD_RESULTS: ReadonlyMap = new Map< ["lastIndexOf", "number"], ["localeCompare", "number"], ["match", "array"], - ["matchAll", "array"], ["normalize", "string"], ["padEnd", "string"], ["padStart", "string"], diff --git a/packages/compiler/src/frontend/shape-inference/index.ts b/packages/compiler/src/frontend/shape-inference/index.ts index 5f5547e39..522e01732 100644 --- a/packages/compiler/src/frontend/shape-inference/index.ts +++ b/packages/compiler/src/frontend/shape-inference/index.ts @@ -366,10 +366,13 @@ function overlaySiteClones(): number { function overlayJsFiles(analysis: ShapeAnalysis): ts.SourceFile[] { // Package JavaScript of packages that keep shapes on, and the program's - // own JavaScript files. + // own JavaScript files. Inference for the program's JavaScript alone + // (SCRIPTC_INFER_JS=1 without SCRIPTC_SHAPE_OVERLAY=1) leaves packages as + // they are. return analysis.files.filter((sf) => { if (sf.isDeclarationFile || !isJsSourceFile(sf)) return false; if (!isPackageJsFile(sf)) return !sf.fileName.includes("/node_modules/"); + if (!shapeOverlayEnabled()) return false; const pkg = npmStaticPackageOfPath(sf.fileName); return pkg === null || !isShapelessPackage(pkg); }); diff --git a/packages/compiler/src/frontend/shape-inference/overlay.ts b/packages/compiler/src/frontend/shape-inference/overlay.ts index bbc547745..db787865e 100644 --- a/packages/compiler/src/frontend/shape-inference/overlay.ts +++ b/packages/compiler/src/frontend/shape-inference/overlay.ts @@ -20,7 +20,7 @@ * keep their lines. */ import * as ts from "../ts7/adapter.js"; import type { Atom } from "./domain.js"; -import type { ShapeAnalysis } from "./analyze.js"; +import { isPackageJsFile, type Op, type ShapeAnalysis } from "./analyze.js"; import type { SiteVerdict } from "./report.js"; export interface ShapeOverlay { @@ -112,6 +112,15 @@ const MAX_RECORD_ARMS = Number(process.env.SCRIPTC_SHAPE_RECORD_ARMS ?? 1); /** The most class or record arms an inferred union may have. */ const MAX_ARMS = 6; +/** Operations whose receivers' class instances keep the dynamic representation. */ +const REFLECTIVE_OPS: ReadonlySet = new Set([ + "computedWrite", + "delete", + "defineProperty", + "setPrototypeOf", + "proxy", +]); + function list(arr: { readonly nodes: readonly T[] } | undefined): readonly T[] { return arr?.nodes ?? []; } @@ -153,6 +162,17 @@ export function planShapeOverlay( } const pSites = new Map(); const siteVerdicts = new Set(verdicts.map((v) => v.site.atom)); + // Class instances that a computed write, `delete`, a property + // definition, a prototype change or a proxy reaches keep the dynamic + // representation in the program's own files: the typed lowering of a + // class instance does not support those operations. + const reflectedInstances = new Set(); + for (const o of analysis.ops) { + if (!REFLECTIVE_OPS.has(o.kind)) continue; + for (const a of analysis.nodes[o.recv]!.atoms) + if (a.kind === "instance" && a.node !== undefined && !isPackageJsFile(a.node.getSourceFile())) + reflectedInstances.add(a); + } const dynHeld = dynHeldSites(analysis, verdicts); // Records and arrays print only for sites whose objects never escape: a // native record that reached untyped code would be seen as a copy. @@ -208,13 +228,32 @@ export function planShapeOverlay( // annotation's id (its position), so a refused lowering can name it and // the next load drop it. `close`: the text that closes a type cast. const patchedSpans = protectedSpansOf(sf.fileName); + // The checker reads a declaration's last JSDoc comment only, so an + // annotation placed after the program's own comment repeats that + // comment's text. + const ownDocBefore = (pos: number): string => { + if (isPackageJsFile(sf)) return ""; + let end = pos; + while (end > 0 && /\s/.test(sf.text[end - 1]!)) end--; + if (end < 5 || sf.text.slice(end - 2, end) !== "*/") return ""; + const start = sf.text.lastIndexOf("/*", end - 3); + if (start < 0 || sf.text[start + 2] !== "*") return ""; + const comment = sf.text.slice(start, end); + if (comment.includes(MARK)) return ""; + return comment + .slice(3, -2) + .replace(/^\s*\*/gm, " ") + .replace(/\s+/g, " ") + .trim(); + }; const annotate = (pos: number, tags: string, close?: number): void => { // A declaration a project type patch edited keeps the patch alone. if (declarationPatched(sf, pos, patchedSpans)) return; + const own = close === undefined ? ownDocBefore(pos) : ""; edits.push({ pos, id: pos, - text: `/** ${tags} ${MARK} #${pos} */ ${close !== undefined ? "(" : ""}`, + text: `/** ${own === "" ? "" : `${own} `}${tags} ${MARK} #${pos} */ ${close !== undefined ? "(" : ""}`, }); if (close !== undefined) edits.push({ pos: close, id: pos, text: ")" }); }; @@ -277,7 +316,7 @@ export function planShapeOverlay( : null; break; case "instance": - t = a.node !== undefined ? className(a.node) : null; + t = a.node !== undefined && !reflectedInstances.has(a) ? className(a.node) : null; break; case "object": { const v = pSites.get(a); @@ -620,12 +659,42 @@ export function planShapeOverlay( return out; }; + // A constructor parameter the constructor stores in a field (`this.value + // = value`) types the field: the checker types a JavaScript field from + // its constructor assignments alone. When other writes give the field + // more values than the parameter's, the parameter stays untyped in the + // program's own files, so the field keeps the dynamic representation. + const storedIntoWiderField = ( + fn: ts.FunctionLikeDeclaration, + param: string, + t: string, + ): boolean => { + if (!ts.isConstructorDeclaration(fn) || fn.body === undefined || isPackageJsFile(sf)) + return false; + const cls = fn.parent; + if (cls === undefined) return false; + return list(fn.body.statements).some((st) => { + if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression)) return false; + const b = st.expression; + return ( + b.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(b.left) && + b.left.expression.kind === ts.SyntaxKind.ThisKeyword && + ts.isIdentifier(b.left.name) && + ts.isIdentifier(b.right) && + b.right.text === param && + fieldType(cls, b.left.name.text) !== t + ); + }); + }; + const paramTags = (fn: ts.FunctionLikeDeclaration): string | null => { const tags: string[] = []; // The package's JSDoc declares types: the overlay adds none where it // has one, and none at all for a signature it types as a whole. const doc = packageDocOf(fn); if (doc.whole) return null; + let opaqueResult = false; if (!ts.isConstructorDeclaration(fn) && !constructed.has(fn) && !doc.returns) { const ret = returnValues(fn); const t = ret !== undefined && ret.size > 0 ? typeOf(ret, false, 0) : null; @@ -642,7 +711,17 @@ export function planShapeOverlay( `[shape-overlay-why] ${fn.name.text}: ${[...ret].map((a) => `${a.kind}:${a.label}${analysis.escaped.has(a) ? "(escaped)" : ""}`).join(" | ")}\n`, ); if (t !== null) tags.push(`@returns {${t}}`); + // The checker types an unannotated method's result from its own + // body, which an override in the program's own files may not match + // (`method(value = 1)` overridden by one returning a string): a + // family whose results print as no type returns `any`. + else if (familyOf(fn).length > 1 && !isPackageJsFile(sf)) tags.push("@returns {any}"); + else + opaqueResult = + ret !== undefined && + [...ret].some((a) => a.kind === "object" || a.kind === "array" || a.kind === "top"); } + const typedParams = tags.length; list(fn.parameters).forEach((p, index) => { if ( !ts.isIdentifier(p.name) || @@ -658,13 +737,44 @@ export function planShapeOverlay( // lowering refuses: such a parameter stays untyped. if (t !== null && typeofTestsOutside(fn, p.name.text, values)) return; if (doc.params.has(p.name.text)) return; + if (t !== null && storedIntoWiderField(fn, p.name.text, t)) return; if (t !== null) tags.push(`@param {${t}} ${p.name.text}`); }); + // With typed parameters the checker infers the result from them: an + // object or array the analysis does not type (a `__proto__` literal, + // a prototype change) would become a record the lowering checks, so + // in the program's own files the result stays `any`. + if (opaqueResult && tags.length > typedParams && !isPackageJsFile(sf)) + tags.push("@returns {any}"); return tags.length > 0 ? tags.join(" ") : null; }; + // Getters of one name in several classes of the program's own file: an + // override can return what the base's body does not (`get value()` + // returning a string over one returning a number), and the checker + // types a read from the getter it resolves, so they return `any`. + const getterClasses = new Map(); + if (!isPackageJsFile(sf)) { + const countGetters = (node: ts.Node): void => { + if (ts.isGetAccessorDeclaration(node) && ts.isIdentifier(node.name)) + getterClasses.set(node.name.text, (getterClasses.get(node.name.text) ?? 0) + 1); + ts.forEachChild(node, (c) => { + countGetters(c); + return undefined; + }); + }; + countGetters(sf); + } + const visit = (node: ts.Node): void => { if (ts.isMethodDeclaration(node)) receiverCasts(node); + if ( + ts.isGetAccessorDeclaration(node) && + ts.isIdentifier(node.name) && + (getterClasses.get(node.name.text) ?? 0) > 1 && + node.type === undefined + ) + annotate(ts.tokenStartOfNode(node), "@returns {any}"); if ( ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index 2b41c2f9d..31477e7b1 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -377,10 +377,17 @@ function writesBinding(id: ts.Identifier): boolean { } function enclosingFunctionLike(n: Node): Node | null { - for (let p = n.parent; p !== undefined; p = p.parent) if (ts.isFunctionLike(p)) return p; + for (let c = n, p = n.parent; p !== undefined; c = p, p = p.parent) + if (runsWithFunction(p, c)) return p; return null; } +/** Whether `child` of `p` runs when the function `p` runs: a method's or + * accessor's computed name runs with its object literal or class. */ +function runsWithFunction(p: Node, child: Node): boolean { + return ts.isFunctionLike(p) && (p as { name?: Node }).name !== child; +} + function primitiveLiteralText(e: ts.Expression): string | null { if ( ts.isNumericLiteral(e) || @@ -1306,8 +1313,8 @@ export function planStaticObjectRewrites( /** Whether `node` sits inside a function of its top-level statement. */ const insideFunction = (node: Node): boolean => { const top = topStatement(node); - for (let p: Node | undefined = node.parent; p !== undefined && p !== top; p = p.parent) - if (ts.isFunctionLike(p)) return true; + for (let c = node, p = node.parent; p !== undefined && p !== top; c = p, p = p.parent) + if (runsWithFunction(p, c)) return true; return false; }; /** The nodes whose statements must have run for an effect-free receiver @@ -1359,8 +1366,8 @@ export function planStaticObjectRewrites( const top = topStatement(read)!; if (!insideFunction(read)) return stmts.indexOf(top) > last; let fn: Node | undefined; - for (let p: Node | undefined = read.parent; p !== undefined && p !== top; p = p.parent) - if (ts.isFunctionLike(p)) fn = p; + for (let c = read, p = read.parent; p !== undefined && p !== top; c = p, p = p.parent) + if (runsWithFunction(p, c)) fn = p; const from = fn !== undefined && ts.isFunctionDeclaration(fn) ? 0 : stmts.indexOf(top); return stmts.slice(from, last + 1).every(definitionOnly); }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json new file mode 100644 index 000000000..f64dbe6f4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/inferred-builtin-method-results.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json new file mode 100644 index 000000000..8dcf96a98 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-binding-union-writes.js" + ], + "diags": [] +} diff --git a/tests/corpus/inferred-builtin-method-results.js b/tests/corpus/inferred-builtin-method-results.js new file mode 100644 index 000000000..60a6a1486 --- /dev/null +++ b/tests/corpus/inferred-builtin-method-results.js @@ -0,0 +1,21 @@ +// Results of string and number methods in untyped code: `at` and +// `codePointAt` past the end read undefined, a number's `valueOf` is a +// number, and `matchAll` yields match arrays with their index. +function charAt(text, i) { + return text.at(i); +} +function codeAt(text, i) { + return text.codePointAt(i); +} +function unwrap(n) { + return n.valueOf(); +} +function pairs(text) { + const out = []; + for (const m of text.matchAll(/(\w)=(\d)/g)) out.push(`${m[1]}${m[2]}@${m.index}`); + return out; +} +console.log(charAt("abc", 1), charAt("abc", 5), typeof charAt("abc", -9)); +console.log(codeAt("ab", 0), codeAt("ab", 9)); +console.log(unwrap(4) + 1, typeof unwrap(4)); +console.log(pairs("a=1 b=2").join(","), pairs("none").length); diff --git a/tests/corpus/js-binding-union-writes.js b/tests/corpus/js-binding-union-writes.js new file mode 100644 index 000000000..b5ceb1e3b --- /dev/null +++ b/tests/corpus/js-binding-union-writes.js @@ -0,0 +1,15 @@ +// JavaScript bindings initialized with a number and later written with a +// value that may be a string: a conditional of a number and a string, and +// `+=` of an element that can be either. Node keeps each value as written. +function pick(c) { + var x = 0; + x = c > 1 ? 1 : "a"; + return x; +} +function total(parts) { + let n = 0; + for (let i = 0; i < parts.length; i++) n += parts[i]; + return n; +} +console.log(pick(0), pick(2), typeof pick(0), typeof pick(2)); +console.log(total([1, 2]), total([1, "a", 2]), typeof total([1, "a"])); diff --git a/tests/harness/js-inference.test.ts b/tests/harness/js-inference.test.ts new file mode 100644 index 000000000..8cd1e4d8b --- /dev/null +++ b/tests/harness/js-inference.test.ts @@ -0,0 +1,95 @@ +/* Shape inference for the program's own JavaScript (SCRIPTC_INFER_JS=1, + * frontend/js-inference.ts): each corpus program below compiles with the + * overlay on its own files and prints what Node prints, with Node's exit + * code. Each covers a case where an inferred type used to be narrower than + * the values the program holds. */ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const corpus = join(repoRoot, "tests/corpus"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/js-inference"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +const programs = [ + // `+=` of a value that may be a string, and a union stored into a number binding. + "7038-js-var-later-writes.mjs", + "7054-let-bindings-with-null-writes.mjs", + // `for await` over a sync iterable, and an iterable object literal with a computed key. + "8380-untyped-generator-loops.mjs", + // `matchAll` yields match arrays. + "6011-match-metadata-js.js", + // `at`, `codePointAt` and a number's `valueOf`. + "inferred-builtin-method-results.js", + "webassembly-wpt-jsapi.mjs", + // Class instances reached by computed writes and property definitions. + "7620-js-class-view-descriptors.js", + // Overridden methods and getters whose results differ. + "js-class-return-shapes.js", + // The program's own JSDoc next to the overlay's. + "class-instance-constructor.js", + // A method called with a proxy as its receiver. + "js-callable-proxy.js", + // A constructor parameter stored into a field that later holds strings. + "js-loose-dynamic-primitives.js", + // A `__proto__: null` literal among a function's results. + "object-literal-members.mjs", + // A computed key read before the binding it names is initialized. + "4090-computed-key-tdz.js", + // Fields an instance may lack. + "8410-dynamic-class-instance-reads.js", +]; + +// Programs where the overlay types nothing: the proxy receiver makes the +// class's methods ⊤, and the computed key keeps its read. +const untyped = new Set(["js-callable-proxy.js", "4090-computed-key-tdz.js"]); + +async function run( + file: string, + args: string[], + cwd: string, +): Promise<{ stdout: string; code: number }> { + try { + const child = execFileAsync(file, args, { cwd, encoding: "utf8" }); + child.child.stdin?.end(); + const { stdout } = await child; + return { stdout, code: 0 }; + } catch (error) { + const failed = error as { stdout?: string; code?: number }; + return { stdout: failed.stdout ?? "", code: failed.code ?? -1 }; + } +} + +async function withInference(build: () => Promise): Promise { + const saved = process.env["SCRIPTC_INFER_JS"]; + process.env["SCRIPTC_INFER_JS"] = "1"; + try { + return await build(); + } finally { + if (saved === undefined) delete process.env["SCRIPTC_INFER_JS"]; + else process.env["SCRIPTC_INFER_JS"] = saved; + } +} + +test.each(programs)("%s matches Node with inference on", { timeout: 900_000 }, async (name) => { + mkdirSync(outDir, { recursive: true }); + const entry = join(corpus, name); + const out = join(outDir, name.replace(/\.m?js$/, "")); + const result = await withInference(() => compile(entry, { outPath: out, outDir, sanitize })); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const [built, node] = await Promise.all([ + run(result.binaryPath, [], dirname(entry)), + run(process.execPath, [entry], dirname(entry)), + ]); + // The overlay typed the program's own files, except where every value + // involved stays untyped. + expect((result.warnings ?? []).some((w) => w.code === "SC6103")).toBe(!untyped.has(name)); + expect(built.stdout).toBe(node.stdout); + expect(built.code).toBe(node.code); +}); From e62348d849352168e7d218b6915a311a35cf26a3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:46:35 -0500 Subject: [PATCH 162/193] Copy record sources field by field in Object.assign onto a live record --- .../assign-typed-record-sources.ts.json | 6 ++ packages/runtime/src/scr_json.c | 28 +++++- tests/corpus/assign-typed-record-sources.ts | 97 +++++++++++++++++++ .../assign-typed-record-sources.ts.json | 1 + 4 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assign-typed-record-sources.ts.json create mode 100644 tests/corpus/assign-typed-record-sources.ts create mode 100644 tests/harness/corpus-costs/assign-typed-record-sources.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assign-typed-record-sources.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assign-typed-record-sources.ts.json new file mode 100644 index 000000000..22c5c410f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assign-typed-record-sources.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/assign-typed-record-sources.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 638c2f1ba..bb4cae8da 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4971,15 +4971,33 @@ static bool scr_typed_ref_record_set(ScrDyn *d, const ScrStr *key, const ScrDyn return scr_typed_ref_record_store(d, ops, index, value); } -/* Object.assign onto a live record from a plain object whose own - * properties are all data properties: each enumerable one that names a - * present declared field stores in place. Answers false at the first key +/* Object.assign onto a live record from another live record, or from a + * plain object whose own properties are all data properties: each + * enumerable one that names a present declared field stores in place. Answers false at the first key * it cannot store, leaving every key to the snapshot path, which stores * the already stored values again unchanged. */ static bool scr_typed_ref_record_assign(ScrDyn *d, const ScrDyn *src) { const ScrDynTypedRecordOps *ops = scr_typed_ref_record_ops(d); - if (!ops || !ops->set || src->kind != SCR_DYN_OBJ || src->symbol_properties || src->symbol_keys) - return false; + if (!ops || !ops->set) return false; + /* A live record source (an object literal boxed as its record) has only + * enumerable string-keyed data fields, in its declared order. */ + const ScrDynTypedRecordOps *from = + src->kind == SCR_DYN_TYPED_REF ? scr_typed_ref_record_ops(src) : NULL; + if (from) { + void *source = src->v.typed_ref.ptr; + for (size_t i = 0; i < from->count; i++) { + if (!from->fixed && !from->has(source, i)) continue; + size_t index; + if (!scr_typed_ref_record_field(ops, from->names[i], &index)) return false; + ScrDyn *value = from->get(source, i); + bool stored = scr_typed_ref_record_store(d, ops, index, value); + scr_dyn_release(value); + if (!stored) return false; + if (scr_exc_pending()) return true; + } + return true; + } + if (src->kind != SCR_DYN_OBJ || src->symbol_properties || src->symbol_keys) return false; size_t count = scr_dyn_obj_count(src); for (size_t i = 0; i < count; i++) { if (scr_dyn_obj_prop_at(src, i).accessor) return false; diff --git a/tests/corpus/assign-typed-record-sources.ts b/tests/corpus/assign-typed-record-sources.ts new file mode 100644 index 000000000..aefc6f63d --- /dev/null +++ b/tests/corpus/assign-typed-record-sources.ts @@ -0,0 +1,97 @@ +// Object.assign onto a typed record through an untyped view, from object +// literals that are typed records too: every field the source has is copied +// in its own order, absent optional fields are skipped, and a source key +// the target does not declare takes the general path. +type Settings = { host: string; port: number; debug: boolean; retries: number; tags: string[] }; +type Patch = { port?: number; debug?: boolean; retries?: number }; + +const settings: Settings = { host: "localhost", port: 80, debug: false, retries: 0, tags: ["a"] }; +const view: object = settings; + +const patches: object[] = [ + { port: 8080 }, + { debug: true, retries: 3 }, + { host: "example.test" }, + { port: 443, debug: false }, + { tags: ["b", "c"] }, +]; +let checksum = 0; +for (let i = 0; i < 2000; i++) { + Object.assign(view, patches[i % patches.length]!); + checksum += settings.port + settings.retries + settings.tags.length; +} +console.log(JSON.stringify(settings), checksum); + +// Optional fields: an absent one is not copied, a present one is. +function patch(retries: number | undefined): Patch { + const p: Patch = { port: 9000 }; + if (retries !== undefined) p.retries = retries; + return p; +} +Object.assign(view, patch(undefined)); +console.log(settings.port, settings.retries); +Object.assign(view, patch(7)); +console.log(settings.port, settings.retries); + +// The source itself as the target, and an empty source. +Object.assign(view, view); +Object.assign(view, {}); +console.log(JSON.stringify(settings)); + +// The returned value is the target. +const same = Object.assign(view, { debug: true }) === view; +console.log(same, settings.debug); + +// A source record whose fields are not all fields of the target. +const extra: object = { port: 1, label: "extra" }; +const copy: Record = { ...settings }; +Object.assign(copy, extra); +console.log(copy["port"], copy["label"], Object.keys(copy).join(",")); + +// A key the target record does not declare becomes an own property of its +// view, and later assignments of declared fields keep it. +type Pair = { host: string; port: number }; +const pair: Pair = { host: "h", port: 1 }; +const pairView: object = pair; +Object.assign(pairView, { port: 2, label: "x" }); +console.log(JSON.stringify(pair), pair.port, Object.keys(pairView).join(",")); +Object.assign(pairView, { port: 3 }); +console.log(JSON.stringify(pair), pair.port); + +// A class instance source copies its own fields, not its prototype's +// accessors. +class Source { + port = 7; + get host(): string { + return "getter"; + } +} +class Derived extends Source { + retries = 9; +} +Object.assign(view, new Source()); +console.log(settings.host, settings.port); +Object.assign(view, new Derived()); +console.log(settings.host, settings.port, settings.retries); + +// A frozen target throws a TypeError and keeps its fields. +const fixed: Pair = Object.freeze({ host: "f", port: 1 }); +const fixedPatch: Partial = { port: 2 }; +try { + Object.assign(fixed as object, fixedPatch); + console.log("assigned", fixed.port); +} catch (error) { + console.log((error as Error).name, fixed.port); +} + +// Array and record fields are copied by reference. +type Holder = { host: string; tags: string[]; inner: { n: number } }; +type HolderPatch = { tags?: string[]; inner?: { n: number } }; +const holder: Holder = { host: "h", tags: ["a"], inner: { n: 1 } }; +const shared = ["x"]; +const sharedInner = { n: 5 }; +const holderPatch: HolderPatch = { tags: shared, inner: sharedInner }; +Object.assign(holder as object, holderPatch); +shared.push("y"); +sharedInner.n = 6; +console.log(holder.tags === shared, holder.inner === sharedInner, holder.tags.join(","), holder.inner.n); diff --git a/tests/harness/corpus-costs/assign-typed-record-sources.ts.json b/tests/harness/corpus-costs/assign-typed-record-sources.ts.json new file mode 100644 index 000000000..94d9cf700 --- /dev/null +++ b/tests/harness/corpus-costs/assign-typed-record-sources.ts.json @@ -0,0 +1 @@ +{ "plain": 5900, "san": 56800 } From d8f3565ac496a544ad588d0f5fbd39c978dcfa5b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 18:55:44 -0500 Subject: [PATCH 163/193] Call untyped write completion callbacks with the error argument A write completion callback that a JavaScript module returned reaches the stream lowering as an untyped value. Since the error-argument form of write callbacks, the lowering refused it with SC2020, so tests/corpus/8532-stream-saved-callback-receiver failed to compile. Such a callback is now converted to a function of the error argument. The stream calls it unbound with null after a successful write or with the write's error, and ignores what it returns, as Node does. --- .../src/frontend/lowering/lower-stream.ts | 10 +++++-- .../main.ts.json | 7 +++++ .../stream-dynamic-write-callback/main.ts | 28 +++++++++++++++++++ .../stream-dynamic-write-callback/report.js | 11 ++++++++ .../main.ts.json | 1 + 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json create mode 100644 tests/corpus/stream-dynamic-write-callback/main.ts create mode 100644 tests/corpus/stream-dynamic-write-callback/report.js create mode 100644 tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 5da73ed0a..1e9040d0e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6966,11 +6966,15 @@ export function lowerStreamMethodCall( const lowerWriteCb = (cbArg: ts.Expression): IrExpr => { // The stream ignores what the callback returns (`() => count++`, a // stored function returning a value): the value is discarded. + // An untyped callback (a function a JavaScript module returned) is + // called unbound with the error argument, as Node calls it. const raw = lowerer.lowerExpr(cbArg); const cb = - raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1 - ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID)) - : raw; + raw.type.kind === "dyn" + ? lowerer.coerceToExpected(raw, funcOf([DYN], VOID)) + : raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1 + ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID)) + : raw; if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.noLowering( "write completion callbacks of this shape", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json new file mode 100644 index 000000000..424b6bc2f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/stream-dynamic-write-callback/report.js", + "/tests/corpus/stream-dynamic-write-callback/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/stream-dynamic-write-callback/main.ts b/tests/corpus/stream-dynamic-write-callback/main.ts new file mode 100644 index 000000000..dc8e72995 --- /dev/null +++ b/tests/corpus/stream-dynamic-write-callback/main.ts @@ -0,0 +1,28 @@ +// Write completion callbacks from a JavaScript module run unbound with +// Node's arguments: null after a successful write, the _write error after a +// failed one, and the write-after-end error. Their return value is ignored. +import { Writable } from "node:stream"; +import { makeReporter } from "./report.js"; + +const lines: string[] = []; +const sink = new Writable({ + write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(chunk.toString() === "bad" ? new Error("rejected") : null); + }, +}); +sink.on("error", (error) => lines.push(`error event ${error.message}`)); +sink.write("good", makeReporter(lines, "good")); +sink.write("bad", "utf8", makeReporter(lines, "bad")); + +const ended = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + }, +}); +ended.on("error", () => lines.push("ended error event")); +ended.end(); +ended.write("late", makeReporter(lines, "late")); + +setTimeout(() => { + for (const line of lines) console.log(line); +}, 5); diff --git a/tests/corpus/stream-dynamic-write-callback/report.js b/tests/corpus/stream-dynamic-write-callback/report.js new file mode 100644 index 000000000..4cd3c74f4 --- /dev/null +++ b/tests/corpus/stream-dynamic-write-callback/report.js @@ -0,0 +1,11 @@ +// A write completion callback built in JavaScript: it records how it was +// called. Module code is strict, so an unbound call sees `this` undefined. +export function makeReporter(lines, label) { + return function (...args) { + const error = args[0]; + lines.push( + `${label} args=${args.length} error=${error === null ? "null" : error === undefined ? "undefined" : error.message} this=${typeof this}`, + ); + return "ignored"; + }; +} diff --git a/tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json b/tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json new file mode 100644 index 000000000..7e594fe7b --- /dev/null +++ b/tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json @@ -0,0 +1 @@ +{ "plain": 1833, "san": 2753 } From 02ff0eb16ba16b742278d200940e91c8a223aac5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 19:02:23 -0500 Subject: [PATCH 164/193] Read the getter first for classes that a later lowering wave reaches A call rewritten for a getter-only name records the classes it tests. When a later wave reaches another class with the same getter, the fallback call is rewritten again for that class, instead of being skipped, so its instances also read the getter before the arguments. Before, `target.step(arg)` on such an instance threw "target.step is not a function". --- .../lowering/class-dynamic-dispatch.ts | 23 +++++++++---- tests/corpus/dynamic-call-callee-order.mjs | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 7d170d24a..45c51096a 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1214,6 +1214,11 @@ export class ClassDynamicDispatch { * classes included: the value's capsule can be any of its subclasses. */ private readonly boxedStatic = new Set(); private readonly dispatches = new Map(); + /** Per receiver local of a call rewritten for a getter-only name, the + * classes whose instances it already reads the getter on. A later wave + * that reaches another class with the getter rewrites the fallback call + * for that class. */ + private readonly getterReceivers = new Map>(); private readonly properties = new Map(); private readonly computed = new Map< string, @@ -1919,15 +1924,19 @@ export class ClassDynamicDispatch { fn: IrFunction, invoke: IrExpr & { kind: "dynInvoke" }, capsules: readonly string[], + covered: ReadonlySet | undefined, ): IrExpr => { const loc = invoke.loc; const receiverLocal = { - id: `${GETTER_RECEIVER}${fn.locals.length}`, + id: `${GETTER_RECEIVER}${this.getterReceivers.size}`, name: "receiver", type: DYN, mutable: false, }; fn.locals.push(receiverLocal); + const keys = new Set(capsules); + if (covered) for (const key of covered) keys.add(key); + this.getterReceivers.set(receiverLocal.id, keys); const calleeLocal = { id: `%dispatch.callee.${fn.locals.length}`, name: "callee", @@ -3069,12 +3078,14 @@ export class ClassDynamicDispatch { invoke && expr.kind === "dynInvoke" && candidates(invoke.method).length === 0 && - !staticCandidates(invoke.method) && - // The other receivers' call this rewrite left in place. - !(expr.recv.kind === "varRef" && expr.recv.localId.startsWith(GETTER_RECEIVER)) + !staticCandidates(invoke.method) ) { - const capsules = getterCapsules(invoke.method); - if (capsules.length > 0) return getterCall(fn, expr, capsules); + // The other receivers' call a rewrite left in place tests only + // the classes that rewrite did not. + const covered = + expr.recv.kind === "varRef" ? this.getterReceivers.get(expr.recv.localId) : undefined; + const capsules = getterCapsules(invoke.method).filter((key) => !covered?.has(key)); + if (capsules.length > 0) return getterCall(fn, expr, capsules, covered); } if ( !invoke || diff --git a/tests/corpus/dynamic-call-callee-order.mjs b/tests/corpus/dynamic-call-callee-order.mjs index 39ea01c9e..e2bc4bf97 100644 --- a/tests/corpus/dynamic-call-callee-order.mjs +++ b/tests/corpus/dynamic-call-callee-order.mjs @@ -122,3 +122,35 @@ function filtered(value) { } console.log(new Material().map, doubled([1, 2, 3]).join(","), filtered([4, 5]).join(",")); console.log(fixed(3.14159, 2), fixed(new Material(), 3), filtered(new Material())); +// A second class with the same getter, constructed only in a method that +// an untyped call reaches: its instances also read the getter before the +// arguments. +const order = []; +class EarlyRunner { + get step() { + order.push("early-get"); + return (value) => `early-${value}`; + } +} +class LateRunner { + get step() { + order.push("late-get"); + return (value) => `late-${value}`; + } +} +class RunnerFactory { + make() { + return new LateRunner(); + } +} +function noted(value) { + order.push(`arg-${value}`); + return value; +} +function step(target, value) { + return target.step(noted(value)); +} +function made(factory) { + return factory.make(); +} +console.log(step(new EarlyRunner(), 1), step(made(new RunnerFactory()), 2), order.join(",")); From 48dd898db6382f2a8542512e176183be3df613fd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 19:16:06 -0500 Subject: [PATCH 165/193] Support non-Error 'error' payloads and checked-dynamic listeners in listeners() and meta events --- .../limitations/builtin-apis/eventemitter.mdx | 5 +- .../compiler/src/backend/llvm/lib-process.ts | 28 ++ .../frontend/lowering/lower-event-emitter.ts | 303 +++++++++++++++--- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 3 + packages/compiler/src/ir/runtime-features.ts | 2 + packages/compiler/src/ir/validate.ts | 8 + .../compiler/test/emitter-flex-events.test.ts | 11 - .../emitter-computed-name-listeners.ts.json | 6 + .../emitter-listeners-dynamic-typed.ts.json | 6 + .../corpus/emitter-listeners-dynamic.cjs.json | 6 + .../emitter-meta-listeners-dynamic.cjs.json | 6 + ...ter-unhandled-error-payloads-typed.ts.json | 6 + .../emitter-unhandled-error-payloads.cjs.json | 6 + .../diagnostics/ee-computed-listeners.ts.json | 6 - packages/runtime/src/scr_events_emitter.c | 72 ++++- packages/runtime/src/scr_events_emitter.h | 4 + .../corpus/emitter-computed-name-listeners.ts | 14 + .../corpus/emitter-listeners-dynamic-typed.ts | 15 + tests/corpus/emitter-listeners-dynamic.cjs | 32 ++ .../corpus/emitter-meta-listeners-dynamic.cjs | 37 +++ .../emitter-unhandled-error-payloads-typed.ts | 46 +++ .../emitter-unhandled-error-payloads.cjs | 67 ++++ tests/diagnostics/ee-computed-listeners.ts | 6 - .../ee-computed-listeners.ts.txt | 8 - .../emitter-computed-name-listeners.ts.json | 1 + .../emitter-listeners-dynamic-typed.ts.json | 1 + .../emitter-listeners-dynamic.cjs.json | 1 + .../emitter-meta-listeners-dynamic.cjs.json | 1 + ...ter-unhandled-error-payloads-typed.ts.json | 1 + .../emitter-unhandled-error-payloads.cjs.json | 1 + 31 files changed, 614 insertions(+), 96 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-computed-name-listeners.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic-typed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-dynamic.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads-typed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads.cjs.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-computed-listeners.ts.json create mode 100644 tests/corpus/emitter-computed-name-listeners.ts create mode 100644 tests/corpus/emitter-listeners-dynamic-typed.ts create mode 100644 tests/corpus/emitter-listeners-dynamic.cjs create mode 100644 tests/corpus/emitter-meta-listeners-dynamic.cjs create mode 100644 tests/corpus/emitter-unhandled-error-payloads-typed.ts create mode 100644 tests/corpus/emitter-unhandled-error-payloads.cjs delete mode 100644 tests/diagnostics/ee-computed-listeners.ts delete mode 100644 tests/harness/__snapshots__/ee-computed-listeners.ts.txt create mode 100644 tests/harness/corpus-costs/emitter-computed-name-listeners.ts.json create mode 100644 tests/harness/corpus-costs/emitter-listeners-dynamic-typed.ts.json create mode 100644 tests/harness/corpus-costs/emitter-listeners-dynamic.cjs.json create mode 100644 tests/harness/corpus-costs/emitter-meta-listeners-dynamic.cjs.json create mode 100644 tests/harness/corpus-costs/emitter-unhandled-error-payloads-typed.ts.json create mode 100644 tests/harness/corpus-costs/emitter-unhandled-error-payloads.cjs.json diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index 0e085c56f..6bd1dab79 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -1,10 +1,11 @@ - **EventEmitter:** Literal event names use fixed argument tuples. Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion. - Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported. + Unrestricted names, symbols, and names that may reach internal events are unsupported. An emitter reached through an untyped value shares one listener list with its typed uses, so untyped registrations and emits reach typed listeners and typed emits reach untyped listeners. An untyped `emit` of an event that has a listener with typed parameters converts its arguments to the event's typed tuple before any listener runs, and throws a `TypeError` when they do not convert. Through an untyped value, `listeners()` and `rawListeners()` of an event with a listener that has typed parameters are unsupported, and `emit('error')` of an object that is not an Error with no `error` listener is unsupported. - Untyped `newListener` and `removeListener` listeners receive `undefined` in place of a listener registered with typed parameters. + A `newListener` or `removeListener` listener registered through an untyped value receives `undefined` in place of a listener registered with typed parameters. + A typed `error` or `errorMonitor` listener throws a `TypeError` when an emitted payload that is not an Error does not convert to its parameter. Reading EventEmitter methods as properties through an untyped value (`typeof emitter.listeners`) answers `undefined`. `events.once` accepts EventEmitter and stream instances with a literal event name and an optional object-literal `{ signal }`; a stream's `data` event, other options values, and EventTarget emitters are unsupported. Reading `defaultMaxListeners` through a CommonJS destructured or member binding (`const { defaultMaxListeners } = require("events")`) is unsupported. diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index d8b94fac0..fe0ad7dc7 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -951,6 +951,34 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr host.emitPendingCheck(); return { name: t, type: e.type }; } + if (e.fn === "emitter.emitErrorValue") { + // (recv, ...payload as dyn): emit('error') with a non-Error payload. + // The unhandled case renders the payload with util.inspect + // (scr_insp_dyn), which this call links. + const args = e.args.map((a) => host.emitExpr(a)); + const n = args.length - 1; + let payload = "null"; + if (n > 0) { + const arr = B.slot(); + B.entryAllocas.push(`${arr} = alloca [${n} x ptr]`); + for (let i = 0; i < n; i++) { + const slot = B.tmp(); + B.line(`${slot} = getelementptr inbounds [${n} x ptr], ptr ${arr}, i64 0, i64 ${i}`); + B.line(`store ptr ${args[i + 1]!.name}, ptr ${slot}`); + } + payload = arr; + } + host.declare(`declare ptr @scr_insp_dyn(ptr, double, double)`); + host.declare( + `declare zeroext i1 @scr_emitter_emit_error_value(ptr, ptr, ${host.sizeType}, ptr)`, + ); + const t = B.tmp(); + B.line( + `${t} = call i1 @scr_emitter_emit_error_value(ptr ${args[0]!.name}, ptr ${payload}, ${host.sizeType} ${n}, ptr @scr_insp_dyn)`, + ); + host.emitPendingCheck(); + return { name: t, type: e.type }; + } if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") { const args = e.args.map((a) => host.emitExpr(a)); const n = args.length - 2; diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 92cc7f23d..07efba81d 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -44,6 +44,7 @@ * super.emit as the prototype chain). */ import { keyedHelperName } from "./stable-names.js"; import { checkKeypressListener } from "./builtins/readline.js"; +import { stripTypeCasts } from "./builtins/arguments.js"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { dynFallbackType, newFnCtx } from "./lowerer.js"; @@ -120,6 +121,54 @@ export interface EventSig { unmappedEmit?: true; /** A listener with any concrete parameter still needs the fixed ABI. */ typedListener?: true; + /** listeners(), rawListeners() or events.getEventListeners() reads the + * event's listener array. With a dyn-flavored listener present, every + * registration of the event goes through the dyn adapter, so the array + * holds checked-dynamic function values (listenerArrayIsDyn). */ + listenersRead?: true; + /** ('error' only) An emit passes a payload that is not statically an + * Error, which typed listeners of 'error' and errorMonitor receive as a + * checked-dynamic value. */ + valueEmit?: true; +} + +/** Whether `t` is an instance type of the Error hierarchy. */ +function rootsAtError(lowerer: Lowerer, t: IrType | null): boolean { + if (t?.kind !== "object") return false; + for (let c: ClassInfo | null = lowerer.classes.get(t.className) ?? null; c; c = c.base) { + if (c.def.name === "%Error") return true; + } + return false; +} + +/** Whether a typed listener of `name` registers through the dyn adapter, so + * its entry holds a checked-dynamic function value: a dyn-flavored + * 'newListener' or 'removeListener' listener receives every added or + * removed listener as one, and a read listener array beside dyn-flavored + * listeners holds them. Listeners of 'error' and errorMonitor do when an + * emit passes a payload that is not an Error: the adapter's checked + * conversion admits it or throws a TypeError. */ +function registersAsDyn(lowerer: Lowerer, name: string, streamForced: boolean): boolean { + const table = emitterEvents(lowerer); + if ([...META_EVENTS].some((meta) => table.get(meta)?.dynListener === true)) return true; + if ((name === "error" || name === ERROR_MONITOR_EVENT) && table.get("error")?.valueEmit === true) + return true; + const sig = streamForced ? undefined : table.get(name); + return sig?.dynListener === true && sig.listenersRead === true; +} + +/** Whether the event's listener array is read as checked-dynamic values: + * its registrations all hold a checked-dynamic function value (flexible + * and computed-name events, or a dyn-flavored listener beside a read of + * the array). */ +function listenerArrayIsDyn(lowerer: Lowerer, name: string, sig: EventSig | undefined): boolean { + return ( + (sig?.dynListener === true && sig.listenersRead === true) || + flexibleJsEvent(name, sig) || + computedPatterns(lowerer).some( + (pattern) => pattern.listener && patternMatchesName(pattern, name), + ) + ); } function flexibleJsEvent(name: string, sig: EventSig | undefined): boolean { @@ -286,12 +335,48 @@ function emitterEvents(lowerer: Lowerer): Map { } }; + // Events whose listener array is read; applied to the table after the + // walk, so a read alone adds no entry. + const listenersRead = new Set(); for (const sf of lowerer.program.getImplementationSourceFiles()) { if (sf.isDeclarationFile) continue; const walk = (node: ts.Node): void => { ts.forEachChild(node, walk); - if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return; - const member = node.expression.name.text; + if (!ts.isCallExpression(node)) return; + const callee = node.expression; + if ( + (ts.isIdentifier(callee) || ts.isPropertyAccessExpression(callee)) && + (ts.isIdentifier(callee) ? callee : callee.name).text === "getEventListeners" && + node.arguments.nodes.length === 2 + ) { + const nameT = (() => { + try { + return lowerer.typeOf(node.arguments.nodes[1]!); + } catch { + return null; + } + })(); + if (nameT?.isStringLiteralType()) listenersRead.add(literalEventKey(nameT.value)); + return; + } + if (!ts.isPropertyAccessExpression(callee)) return; + const member = callee.name.text; + if (member === "listeners" || member === "rawListeners") { + const arg = node.arguments.nodes[0]; + let nameT: ts.Type | null = null; + try { + if ( + !arg || + !scannedEmitterInfo(lowerer, lowerer.mapTypeOf(lowerer.typeOf(callee.expression))) + ) + return; + nameT = lowerer.typeOf(arg); + } catch { + return; + } + if (nameT.isStringLiteralType()) listenersRead.add(literalEventKey(nameT.value)); + return; + } const isEmit = member === "emit"; if (!isEmit && !REGISTER_MEMBERS.has(member)) return; const arg0 = node.arguments.nodes[0]; @@ -299,7 +384,7 @@ function emitterEvents(lowerer: Lowerer): Map { let recvT: IrType | null = null; let nameT: ts.Type | null = null; try { - recvT = lowerer.mapTypeOf(lowerer.typeOf(node.expression.expression)); + recvT = lowerer.mapTypeOf(lowerer.typeOf(callee.expression)); nameT = lowerer.typeOf(arg0); } catch { return; // checker trouble is the lowering's business, not the scan's @@ -323,6 +408,46 @@ function emitterEvents(lowerer: Lowerer): Map { return; } const name = literal; + if (META_EVENTS.has(name) && !isEmit && node.arguments.nodes[1]) { + // A dyn-flavored meta listener receives each added or removed + // listener as a checked-dynamic function value. The scan cannot + // lower the listener as dynListenerFlavor does, so it counts every + // listener that is not statically a typed function: a factory's + // result (a wrapper whose runtime value is checked-dynamic), a + // JavaScript function without parameters, and the dyn-flavored + // forms. Counting one too many only routes more registrations + // through the adapter. + const cbNode = node.arguments.nodes[1]; + let typed = false; + try { + const cbCt = lowerer.typeOf(cbNode); + const cbT = lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, cbNode, cbCt); + typed = + cbT?.kind === "func" && + !ts.isCallExpression(stripTypeCasts(cbNode)) && + !(cbT.params.length === 0 && isJsSourceFile(sf)) && + !cbT.params.some((p) => p.kind === "dyn") && + !readsArgumentsPack(lowerer, cbNode); + } catch { + typed = false; + } + if (!typed) table.get(name)!.dynListener = true; + return; + } + if (isEmit && name === "error") { + try { + const payload = node.arguments.nodes.slice(1); + if ( + payload.length !== 1 || + !rootsAtError(lowerer, lowerer.mapTypeOf(lowerer.typeOf(payload[0]!))) + ) { + table.get("error")!.valueEmit = true; + } + } catch { + table.get("error")!.valueEmit = true; + } + return; + } if (META_EVENTS.has(name) || name === "error" || name === ERROR_MONITOR_EVENT) return; // forced tuples // Stream receivers' runtime-emitted events carry PER-BASE forced // tuples (lower-stream.ts) — their sites never join the program- @@ -377,6 +502,10 @@ function emitterEvents(lowerer: Lowerer): Map { }; walk(sf); } + for (const name of listenersRead) { + const sig = table.get(name); + if (sig) sig.listenersRead = true; + } lowerer.emitterEventTable = table; lowerer.emitterComputedPatterns = computedPatterns; return table; @@ -604,6 +733,7 @@ function lowerFlexListenerCall( once: boolean, prepend: boolean, loc: SrcLoc, + onFn: "emitter.onFlex" | "emitter.onAny" = "emitter.onFlex", ): IrExpr { if (receiver.type.kind !== "object") { lowerer.noLowering( @@ -633,10 +763,10 @@ function lowerFlexListenerCall( lowerer.noLowering(`${member} with a non-function computed-name listener`, cbNode); } const recvT = receiver.type; - const key = `emitter.${registering ? "onFlex" : "offDyn"}:${typeKey(recvT)}`; + const key = `${registering ? onFn : "emitter.offDyn"}:${typeKey(recvT)}`; let helper = lowerer.arrHofHelpers.get(key); if (!helper) { - helper = keyedHelperName(lowerer, `%emitter.${registering ? "onFlex" : "offFlex"}`, key); + helper = keyedHelperName(lowerer, `%${registering ? onFn : "emitter.offFlex"}`, key); lowerer.arrHofHelpers.set(key, helper); const params: IrParam[] = [ { localId: "r.0", name: "r", type: recvT }, @@ -671,7 +801,7 @@ function lowerFlexListenerCall( kind: "return", value: { kind: "libCall", - fn: registering ? "emitter.onFlex" : "emitter.offDyn", + fn: registering ? onFn : "emitter.offDyn", args: [ varRef("r.0", recvT, loc), varRef("n.0", STRING, loc), @@ -699,6 +829,59 @@ function lowerFlexListenerCall( loc, }; } +/** A lowered value as a checked-dynamic payload: boxed as any untyped + * value is (records and arrays keep their identity), otherwise a refusal + * at the argument. */ +function boxedPayload(lowerer: Lowerer, value: IrExpr, node: ts.Expression): IrExpr { + if (value.type.kind === "dyn") return value; + if ( + value.kind === "unitLit" || + canConvertToDyn( + value.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + return lowerer.coerceToExpected(value, DYN); + } + lowerer.noLowering( + `emit('error') with a '${lowerer.fmt(value.type)}' payload`, + node, + "the payload cannot box into a dynamic value", + ); +} + +/** The listener array of an event whose registrations all hold a checked- + * dynamic function value (listenerArrayIsDyn), each comparing equal to the + * function that was registered. JavaScript sources read the untyped array, + * whose functions take every argument a call passes. TypeScript sources + * read the element type the typed path declares, the event's tuple: a + * listener of exactly that signature unwraps to the original closure. */ +function dynListenerArray( + lowerer: Lowerer, + table: Map, + receiver: IrExpr, + name: string, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const array: IrExpr = { + kind: "libCall", + fn: "emitter.listenersDyn", + args: [receiver, eventNameExpr(name, loc)], + type: DYN, + loc, + }; + if (isJsSourceFile(ts.getSourceFileOfNode(call))) return array; + const tuple = tupleOf(lowerer, table, name, call); + return { + kind: "dynCheck", + value: array, + type: arrayOf({ kind: "func", params: tuple, ret: { kind: "void" } }), + loc, + }; +} + /** The event's unified tuple, with conflicts reported at this site. */ function tupleOf( lowerer: Lowerer, @@ -820,10 +1003,29 @@ function lowerDynListenerCall( streamData: boolean, ): IrExpr { if (META_EVENTS.has(name)) { - lowerer.noLowering( - `'${member}' of '${name}' with a checked-dynamic listener`, + // The registration installs the registry's dynamic half: the listener + // receives the event name and the added or removed listener's + // checked-dynamic function value, as in Node. Every other listener + // then registers through the adapter (registersAsDyn), which the event + // table's scan arranges. + if (registering && emitterEvents(lowerer).get(name)?.dynListener !== true) { + lowerer.noLowering( + `'${member}' of '${name}' with a checked-dynamic listener the event table did not record`, + cbNode, + "register the listener directly at this call", + ); + } + return lowerFlexListenerCall( + lowerer, + member, + eventNameExpr(name, loc), + receiver, cbNode, - "Node passes the listener FUNCTION as the meta event's second argument, which has no dynamic conversion — annotate the listener's parameter as a string", + registering, + once, + prepend, + loc, + "emitter.onAny", ); } const getRecord = (id: string) => lowerer.shapes.get(id); @@ -1490,7 +1692,9 @@ export function lowerEmitterMethodCall( // to the declared type, or take the value (dyn). const sides = streamSidesOf(lowerer, info); if (name === "data" && (sides === "r" || sides === "rw")) { - const dynFlavor = dynListenerFlavor(lowerer, args[1]!); + const dynFlavor = + dynListenerFlavor(lowerer, args[1]!) ?? + (registersAsDyn(lowerer, name, true) ? "func" : null); if (dynFlavor !== null) { return lowerDynListenerCall( lowerer, @@ -1575,7 +1779,11 @@ export function lowerEmitterMethodCall( const probe = tryLowerExpression(lowerer, args[1]!); if (probe) checkKeypressListener(lowerer, args[1]!, probe); } - const dynFlavor = dynListenerFlavor(lowerer, args[1]!) ?? (keypress ? "func" : null); + const dynFlavor = + dynListenerFlavor(lowerer, args[1]!) ?? + (keypress || registersAsDyn(lowerer, name, streamForcedTuple(lowerer, info, name) !== null) + ? "func" + : null); if (dynFlavor !== null) { return lowerDynListenerCall( lowerer, @@ -1654,29 +1862,36 @@ export function lowerEmitterMethodCall( ); const receiver = lowerReceiver(); if (name === "error") { - // The special event: exactly one %Error-rooted payload; no listener - // means the runtime THROWS it (emitter.emitError, may-throw). - if (args.length !== 2) { - lowerer.noLowering( - `emit('error') with ${args.length - 1} payload arguments`, - call, - "the supported form is emit('error', err) with an Error-hierarchy payload (non-Error payloads would need Node's ERR_UNHANDLED_ERROR wrapping — no lowering yet)", - ); - } - const err = lowerer.lowerExpr(args[1]!); - const rootsAtError = (t: IrType): boolean => { - if (t.kind !== "object") return false; - for (let c: ClassInfo | null = lowerer.classes.get(t.className) ?? null; c; c = c.base) { - if (c.def.name === "%Error") return true; + // The special event: one %Error-rooted payload; no listener means the + // runtime THROWS it (emitter.emitError, may-throw). + const err = args.length === 2 ? lowerer.lowerExpr(args[1]!) : null; + if (err === null || !rootsAtError(lowerer, err.type)) { + // Any other payload, or another argument count: the arguments box + // to dyn. With no 'error' listener the runtime throws Node's + // ERR_UNHANDLED_ERROR (the payload's util.inspect in the message, + // the payload as `context`); listeners receive the arguments as + // given. + if (emitOverrideAtOrAbove(info) || superRecv !== undefined) { + lowerer.noLowering( + `emit('error') with a non-Error payload through an emit override`, + call, + "the override specialization takes the event's one Error payload", + ); } - return false; - }; - if (!rootsAtError(err.type)) { - lowerer.noLowering( - `emit('error') with a '${lowerer.fmt(err.type)}' payload`, - args[1]!, - "the supported payload is an Error-hierarchy instance", - ); + const payload = args + .slice(1) + .map((arg, i) => + i === 0 && err !== null + ? boxedPayload(lowerer, err, arg) + : lowerer.lowerExprExpecting(arg, DYN), + ); + return { + kind: "libCall", + fn: "emitter.emitErrorValue", + args: [receiver, ...payload], + type: BOOL, + loc, + }; } return emitDispatchExpr( lowerer, @@ -1824,13 +2039,10 @@ export function lowerEmitterMethodCall( } const name = eventNameOf(lowerer, member, args[0]!); const sig = table.get(name); - if ( - sig?.dynListener || - flexibleJsEvent(name, sig) || - computedPatterns(lowerer).some( - (pattern) => pattern.listener && patternMatchesName(pattern, name), - ) - ) { + if (listenerArrayIsDyn(lowerer, name, sig)) { + return dynListenerArray(lowerer, table, lowerReceiver(), name, call, loc); + } + if (sig?.dynListener) { // A dyn-adapted registration means the runtime bucket can hold // originals of MIXED signatures — no one honest element type. lowerer.noLowering( @@ -2809,13 +3021,10 @@ export function lowerEventsEmitterHelper( const name = eventNameOf(lowerer, "events.getEventListeners", args[1]!); const table = emitterEvents(lowerer); const sig = table.get(name); - if ( - sig?.dynListener || - flexibleJsEvent(name, sig) || - computedPatterns(lowerer).some( - (pattern) => pattern.listener && patternMatchesName(pattern, name), - ) - ) { + if (listenerArrayIsDyn(lowerer, name, sig)) { + return dynListenerArray(lowerer, table, receiver, name, call, loc); + } + if (sig?.dynListener) { lowerer.noLowering( `events.getEventListeners of the event '${name}'`, call, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index dce26c486..4ee5fc609 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -317,6 +317,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "emitter.emit", "emitter.emitFlex", "emitter.emitAny", + "emitter.emitErrorValue", "emitter.emitMixed", "emitter.listenersDyn", "emitter.emitError", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 844e7ed83..7c9b5a0fa 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4349,6 +4349,9 @@ function buildLibFnSignatures() { "emitter.emit": { argTypes: [null, STRING], result: BOOL }, "emitter.emitFlex": { argTypes: [null, STRING], result: BOOL }, "emitter.emitAny": { argTypes: [null, STRING], result: BOOL }, + /** emit('error', ...payload) with a non-Error payload: (recv, then the + * arguments boxed) — VARIADIC like emitFlex. */ + "emitter.emitErrorValue": { argTypes: [null], result: BOOL }, /** An untyped emit of an event with a fixed tuple: (recv, name, k, then * k checked-dynamic arguments as given, then the event's converted * tuple) — VARIADIC like emitter.emit. hasTyped answers whether a diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index cf237747b..2bff90234 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1121,6 +1121,8 @@ const RUNTIME_FEATURE_RULES = { "webstream.class", "webstream.install", "util.styleText", + // ERR_UNHANDLED_ERROR quotes util.inspect of a non-Error payload. + "emitter.emitErrorValue", "crypto.cipherNew", "webcrypto.encrypt", "webcrypto.decrypt", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 8d3a735ee..0ab7ee196 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4154,6 +4154,7 @@ function validateFunction( e.fn === "emitter.emit" || e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny" || + e.fn === "emitter.emitErrorValue" || e.fn === "emitter.emitMixed" || e.fn === "readable.new" || e.fn === "writable.new" || @@ -6191,6 +6192,12 @@ function validateFunction( err(`libCall ${e.fn} payloads must be checked-dynamic`, e.loc); } } + if ( + e.fn === "emitter.emitErrorValue" && + e.args.slice(1).some((arg) => arg.type.kind !== "dyn") + ) { + err(`libCall emitter.emitErrorValue payloads must be checked-dynamic`, e.loc); + } if (e.fn === "emitter.emitMixed") { const count = e.args[2]; const k = count?.kind === "numLit" ? count.value : -1; @@ -6204,6 +6211,7 @@ function validateFunction( e.fn === "emitter.emit" || e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny" || + e.fn === "emitter.emitErrorValue" || e.fn === "emitter.emitMixed" || e.fn === "emitter.hasTyped" || e.fn === "emitter.listenersDyn" || diff --git a/packages/compiler/test/emitter-flex-events.test.ts b/packages/compiler/test/emitter-flex-events.test.ts index 8677e9ecd..fa21ca349 100644 --- a/packages/compiler/test/emitter-flex-events.test.ts +++ b/packages/compiler/test/emitter-flex-events.test.ts @@ -17,17 +17,6 @@ test.each([ const ee = new Derived(); ee.on('value', (value) => console.log(value)); ee.emit('value'); ee.emit('value', 1);`, "variable-arity emit through an EventEmitter subclass overriding emit", ], - [ - "listener arrays", - `const ee = new EventEmitter(); ee.on('value', () => {}); - ee.emit('value'); ee.emit('value', 1); console.log(ee.listeners('value'));`, - "listeners of the event 'value'", - ], - [ - "special error events", - `const ee = new EventEmitter(); ee.on('error', (value) => console.log(value)); ee.emit('error');`, - "emit('error') with 0 payload arguments", - ], [ "reserved stream events", `const ee = new EventEmitter(); ee.on('data', (value) => console.log(value)); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-computed-name-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-computed-name-listeners.ts.json new file mode 100644 index 000000000..a507a6479 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-computed-name-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-computed-name-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic-typed.ts.json new file mode 100644 index 000000000..e3b8e0a8a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-listeners-dynamic-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic.cjs.json new file mode 100644 index 000000000..08d14e258 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-listeners-dynamic.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-listeners-dynamic.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-dynamic.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-dynamic.cjs.json new file mode 100644 index 000000000..ae062ddc9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-dynamic.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-meta-listeners-dynamic.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads-typed.ts.json new file mode 100644 index 000000000..69794a8b2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-unhandled-error-payloads-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads.cjs.json new file mode 100644 index 000000000..adbfaf1d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-unhandled-error-payloads.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-unhandled-error-payloads.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-computed-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-computed-listeners.ts.json deleted file mode 100644 index e5e275d35..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-computed-listeners.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/ee-computed-listeners.ts" - ], - "diags": [] -} diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 002f16f5a..c72662670 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -1193,13 +1193,29 @@ bool scr_emitter_has_typed(ScrEmitter *em, ScrStr *name) { return false; } -/* Node's ERR_UNHANDLED_ERROR for a non-Error payload: the message quotes - * util.inspect of the payload. This unit renders the primitive payloads - * inspect prints plainly; others report that they are not supported. */ -static void scr_ee_unhandled_error(const ScrDyn *er) { +/* Throws Node's ERR_UNHANDLED_ERROR for a non-Error payload: the message + * quotes `shown` (util.inspect of the payload) and `context` is the + * payload. */ +SCR_COLD static void scr_ee_throw_unhandled(const char *shown, size_t len, const ScrDyn *er) { ScrJsonBuf text; scr_jb_init(&text); scr_jb_puts(&text, "Unhandled error. ("); + scr_jb_write(&text, shown, len); + scr_jb_putc(&text, ')'); + ScrStr *message = scr_jb_finish(&text); + ScrError *err = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + scr_error_set_code(err, "ERR_UNHANDLED_ERROR"); + scr_error_set_prop(err, "context", 7, scr_dyn_retain((ScrDyn *)er)); + scr_throw_obj(err, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + +/* ERR_UNHANDLED_ERROR through an untyped value. This unit renders the + * primitive payloads inspect prints plainly; others report that they are + * not supported. */ +static void scr_ee_unhandled_error(const ScrDyn *er) { + ScrJsonBuf text; + scr_jb_init(&text); bool shown = true; switch (er->kind) { case SCR_DYN_UNDEF: scr_jb_puts(&text, "undefined"); break; @@ -1233,13 +1249,7 @@ static void scr_ee_unhandled_error(const ScrDyn *er) { } ScrStr *message = scr_jb_finish(&text); if (shown) { - ScrJsonBuf full; - scr_jb_init(&full); - scr_jb_write(&full, message->data, message->len); - scr_jb_putc(&full, ')'); - ScrStr *rendered = scr_jb_finish(&full); - scr_throw_error_msg_code(SCR_ERR_ERROR, rendered->data, rendered->len, "ERR_UNHANDLED_ERROR"); - scr_str_release(rendered); + scr_ee_throw_unhandled(message->data, message->len, er); } else { const char *refusal = "emit('error') through an untyped value with this non-Error payload and no 'error' listener is not supported yet"; @@ -1251,16 +1261,35 @@ static void scr_ee_unhandled_error(const ScrDyn *er) { /* emit('error', ...) through an untyped value: errorMonitor first; with no * 'error' listener an Error payload throws itself and anything else throws * ERR_UNHANDLED_ERROR, Node's contract; otherwise listeners receive the - * arguments as given, and typed listeners the payload as an Error. */ -static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { + * arguments as given, and typed listeners the payload as an Error. `render` + * (util.inspect, when the call site links it) quotes the payload in the + * ERR_UNHANDLED_ERROR message; without it only primitive payloads render. */ +static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, + ScrStr *(*render)(ScrDyn *, double, double)) { const ScrDyn *er = argc ? args[0] : scr_dyn_undefined(); ScrError *err = scr_dyn_err_instanceof(er, SCR_ERR_ERROR) ? scr_errdyn_err_of(er) : NULL; bool had = false; if (!scr_ee_has(em->reg, "error")) { scr_ee_emit_mixed_va(em, SCR_EE_ERROR_MONITOR, args, argc, err != NULL, err); if (!scr_exc_pending()) { - if (err) scr_dyn_throw(scr_dyn_retain((ScrDyn *)er)); - else scr_ee_unhandled_error(er); + if (err) { + scr_dyn_throw(scr_dyn_retain((ScrDyn *)er)); + } else if (render) { + ScrStr *shown = render((ScrDyn *)er, 0, 2); + if (scr_exc_pending()) { + /* A throwing inspect: Node quotes the payload's string conversion + * instead, whose own throw propagates. */ + if (shown) scr_str_release(shown); + scr_exc_clear(); + shown = scr_dyn_string_coerce_js(er); + } + if (shown) { + scr_ee_throw_unhandled(shown->data, shown->len, er); + scr_str_release(shown); + } + } else { + scr_ee_unhandled_error(er); + } } } else { had = scr_ee_emit_mixed_va(em, name, args, argc, err != NULL, err); @@ -1269,6 +1298,17 @@ static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *a return had; } +/* emit('error', ...args) from typed code whose payload is not statically + * an Error (the arguments boxed): scr_ee_emit_error_dyn, quoting the + * payload with `render`. */ +bool scr_emitter_emit_error_value(ScrEmitter *em, ScrDyn *const *args, size_t argc, + ScrStr *(*render)(ScrDyn *, double, double)) { + ScrStr *name = scr_str_new("error", 5); + bool had = scr_ee_emit_error_dyn(em, name, args, argc, render); + scr_str_release(name); + return had; +} + /* An untyped emit of a name typed code never fixes a tuple for (computed * names, runtime-internal registries): the arguments as given. */ bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { @@ -1288,7 +1328,7 @@ bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMess * typed listeners with a string name. */ bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { if (!scr_ee_is_error_monitor(name)) { - if (scr_ee_is_name(name, "error")) return scr_ee_emit_error_dyn(em, name, args, argc); + if (scr_ee_is_name(name, "error")) return scr_ee_emit_error_dyn(em, name, args, argc, NULL); if (scr_ee_is_name(name, "newListener") || scr_ee_is_name(name, "removeListener")) { ScrStr *s = argc && args[0]->kind == SCR_DYN_STR ? args[0]->v.str : NULL; return scr_ee_emit_mixed_va(em, name, args, argc, s != NULL, s); diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index 29d4c397f..94578150f 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -109,6 +109,10 @@ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrEmitter *scr_emitter_on_any(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, bool once, bool prepend); bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); +/* emit('error', ...args) with a non-Error payload from typed code, the + * arguments boxed; `render` is util.inspect's dyn entry (scr_insp_dyn). */ +bool scr_emitter_emit_error_value(ScrEmitter *em, ScrDyn *const *args, size_t argc, + ScrStr *(*render)(ScrDyn *, double, double)); ScrEmitter *scr_emitter_off_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb); /* The registry's checked-dynamic half (meta-event argument vectors and * rebuilt listener values), installed by the first on_any call; NULL in diff --git a/tests/corpus/emitter-computed-name-listeners.ts b/tests/corpus/emitter-computed-name-listeners.ts new file mode 100644 index 000000000..324181e23 --- /dev/null +++ b/tests/corpus/emitter-computed-name-listeners.ts @@ -0,0 +1,14 @@ +// listeners() and rawListeners() from TypeScript of an event registered +// under a computed name: the registered functions in order. +import { EventEmitter } from "node:events"; + +const emitter = new EventEmitter(); +const suffix = process.argv[2] ?? "x"; +const onTopic = (): void => console.log("topic listener"); +emitter.on(`topic:${suffix}`, onTopic); +emitter.on(`topic:${suffix}`, () => console.log("second")); +console.log(emitter.listeners("topic:x").length, emitter.listeners("topic:x")[0] === onTopic); +for (const l of emitter.listeners("topic:x")) l(); +console.log(emitter.listeners("topic:y").length); +emitter.off("topic:x", onTopic); +console.log(emitter.rawListeners("topic:x").length); diff --git a/tests/corpus/emitter-listeners-dynamic-typed.ts b/tests/corpus/emitter-listeners-dynamic-typed.ts new file mode 100644 index 000000000..50b1ef060 --- /dev/null +++ b/tests/corpus/emitter-listeners-dynamic-typed.ts @@ -0,0 +1,15 @@ +// listeners() and rawListeners() from TypeScript of an event with a typed +// listener and a checked-dynamic one: each element is the registered +// function, called with the event's arguments. +import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +const typed = (n: number, s: string): void => console.log("typed", n, s); +const loose = ((...args: unknown[]) => console.log("loose", args.length)) as any; +e.on("tick", typed); +e.on("tick", loose); +e.emit("tick", 1, "one"); +console.log(e.listeners("tick").length, e.listeners("tick")[0] === typed, e.rawListeners("tick")[1] === loose); +for (const l of e.listeners("tick")) (l as (n: number, s: string) => void)(2, "two"); +e.off("tick", typed); +e.emit("tick", 3, "three"); +console.log(e.listenerCount("tick"), e.listeners("tick").length); diff --git a/tests/corpus/emitter-listeners-dynamic.cjs b/tests/corpus/emitter-listeners-dynamic.cjs new file mode 100644 index 000000000..573aaf5f4 --- /dev/null +++ b/tests/corpus/emitter-listeners-dynamic.cjs @@ -0,0 +1,32 @@ +// listeners(), rawListeners() and getEventListeners() of events whose +// listeners include functions with untyped parameters or that read +// arguments: the array holds the registered functions in order. +const { EventEmitter, getEventListeners } = require('events'); +const e = new EventEmitter(); +function typed() { console.log('typed'); } +function packed() { console.log('packed', arguments.length); } +function withParam(a) { console.log('withParam', a); } +e.on('foo', typed); +e.on('foo', packed); +e.once('foo', withParam); +let fl = e.listeners('foo'); +console.log(fl.length, fl[0] === typed, fl[1] === packed, fl[2] === withParam); +const raw = e.rawListeners('foo'); +console.log(raw.length, raw[2] === withParam || typeof raw[2].listener === 'function'); +console.log(getEventListeners(e, 'foo').length); +e.emit('foo', 1, 2); +fl = e.listeners('foo'); +console.log(fl.length); +fl[0](); +fl[1](7, 8, 9); +e.off('foo', typed); +console.log(e.listeners('foo').length, e.listeners('foo')[0] === packed); +e.removeListener('foo', packed); +console.log(e.listeners('foo').length, e.listenerCount('foo')); +const g = new EventEmitter(); +g.on('bar', function (x) { console.log('bar', x); }); +g.on('bar', (x, y) => console.log('bar2', x, y)); +g.emit('bar', 'a'); +g.emit('bar', 'a', 'b', 'c'); +console.log(g.listeners('bar').length, typeof g.listeners('bar')[1]); +console.log(e.listeners('nothing').length); diff --git a/tests/corpus/emitter-meta-listeners-dynamic.cjs b/tests/corpus/emitter-meta-listeners-dynamic.cjs new file mode 100644 index 000000000..d048d33ad --- /dev/null +++ b/tests/corpus/emitter-meta-listeners-dynamic.cjs @@ -0,0 +1,37 @@ +// Checked-dynamic 'newListener' and 'removeListener' listeners receive the +// event name and the added or removed listener function. +const EventEmitter = require('events'); +function listener1() {} +function listener2(a) { console.log('l2', a); } +const ee = new EventEmitter(); +ee.on('newListener', function (name, fn) { + console.log('new', name, fn === listener1, fn === listener2, typeof fn); +}); +ee.on('hello', listener1); +ee.on('hello', listener2); +ee.on('removeListener', (name, cb) => { + console.log('removed', name, cb === listener1, cb === listener2); +}); +ee.removeListener('hello', listener1); +ee.emit('hello', 5); +console.log(ee.listeners('hello').length, ee.listeners('hello')[0] === listener2); +ee.removeAllListeners('hello'); +console.log(ee.listenerCount('hello')); +const other = new EventEmitter(); +other.once('removeListener', (name, cb) => console.log('once-removed', name, typeof cb)); +other.on('x', (n) => console.log('x', n)); +other.emit('x', 1); +other.removeAllListeners(); +console.log(other.eventNames()); + +// A meta listener produced by a wrapper factory, as test helpers do. +function wrap(fn) { + return function () { + return fn.apply(this, arguments); + }; +} +function plain() {} +const wrapped = new EventEmitter(); +wrapped.on('ping', plain); +wrapped.on('removeListener', wrap((name, cb) => console.log('wrapped removed', name, cb === plain))); +wrapped.removeListener('ping', plain); diff --git a/tests/corpus/emitter-unhandled-error-payloads-typed.ts b/tests/corpus/emitter-unhandled-error-payloads-typed.ts new file mode 100644 index 000000000..1bb90e7f7 --- /dev/null +++ b/tests/corpus/emitter-unhandled-error-payloads-typed.ts @@ -0,0 +1,46 @@ +// emit('error', payload) from typed code with a payload that is not an +// Error: Node's ERR_UNHANDLED_ERROR when nothing listens, the payload as +// given otherwise. +import { EventEmitter, errorMonitor } from "node:events"; + +interface UnhandledError extends Error { + code?: string; + context?: unknown; +} + +const e = new EventEmitter(); +const record = { id: 3, tags: ["a", "b"] }; +const attempts: Array<() => boolean> = [ + () => e.emit("error", "plain string"), + () => e.emit("error", 12.5), + () => e.emit("error", record), + () => e.emit("error", [1, 2, 3]), + () => e.emit("error", null), + () => e.emit("error"), + () => e.emit("error", "a", "b"), +]; +for (const attempt of attempts) { + try { + attempt(); + console.log("no throw"); + } catch (caught) { + const err = caught as UnhandledError; + console.log(err.code, err.message, err.context === record, typeof err.context); + } +} + +let monitored = 0; +e.on(errorMonitor, () => { + monitored++; +}); +try { + e.emit("error", "watched"); +} catch (caught) { + console.log((caught as UnhandledError).code, monitored); +} + +const handled = new EventEmitter(); +handled.on("error", (err: unknown) => console.log("got", err instanceof Error ? `Error: ${err.message}` : err)); +console.log(handled.emit("error", 7)); +console.log(handled.emit("error", { reason: "closed" })); +console.log(handled.emit("error", new Error("real"))); diff --git a/tests/corpus/emitter-unhandled-error-payloads.cjs b/tests/corpus/emitter-unhandled-error-payloads.cjs new file mode 100644 index 000000000..01ba59c95 --- /dev/null +++ b/tests/corpus/emitter-unhandled-error-payloads.cjs @@ -0,0 +1,67 @@ +// emit('error', payload) with a payload that is not an Error: with no +// 'error' listener it throws ERR_UNHANDLED_ERROR, whose message quotes +// util.inspect of the payload and whose context is the payload; listeners +// receive the arguments as given. +const { EventEmitter, errorMonitor } = require("events"); + +const e = new EventEmitter(); +const payloads = ["Accepts a string", "it's", { message: "Error!" }, 42, -0, null, undefined, true, [1, "two"], { nested: { deep: [1, 2] } }]; +for (const v of payloads) { + try { + e.emit("error", v); + console.log("no throw"); + } catch (err) { + console.log(err instanceof Error, err.name, err.code, err.message, err.context === v); + } +} + +try { + e.emit("error"); +} catch (err) { + console.log(err.code, err.message, err.context); +} + +try { + e.emit("error", "first", "second"); +} catch (err) { + console.log(err.code, err.message, err.context); +} + +// errorMonitor sees the payload before the throw. +const m = new EventEmitter(); +m.on(errorMonitor, function (v) { + console.log("monitor", v, arguments.length); +}); +try { + m.emit("error", { code: 7 }); +} catch (err) { + console.log("thrown", err.code, err.context.code); +} + +// With an 'error' listener nothing throws. +const l = new EventEmitter(); +l.on("error", function (a, b) { + console.log("listener", a, b, arguments.length); +}); +console.log(l.emit("error", "x", 2)); +console.log(l.emit("error", { k: 1 })); +console.log(l.emit("error")); + +// An Error payload still throws itself. +try { + e.emit("error", new TypeError("typed")); +} catch (err) { + console.log(err.name, err.message, err.code); +} + +// A payload whose util.inspect throws is quoted by its string conversion. +try { + e.emit("error", { + message: "Error!", + [require("util").inspect.custom]() { + throw new Error("no inspect"); + }, + }); +} catch (err) { + console.log(err.code, err.message); +} diff --git a/tests/diagnostics/ee-computed-listeners.ts b/tests/diagnostics/ee-computed-listeners.ts deleted file mode 100644 index babe0f311..000000000 --- a/tests/diagnostics/ee-computed-listeners.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { EventEmitter } from "node:events"; - -const emitter = new EventEmitter(); -const suffix = process.argv[2] ?? "x"; -emitter.on(`topic:${suffix}`, () => {}); -emitter.listeners("topic:x"); diff --git a/tests/harness/__snapshots__/ee-computed-listeners.ts.txt b/tests/harness/__snapshots__/ee-computed-listeners.ts.txt deleted file mode 100644 index 46181c0e8..000000000 --- a/tests/harness/__snapshots__/ee-computed-listeners.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -ee-computed-listeners.ts:6:1 - error SC2020: 'listeners of the event 'topic:x'' is part of the standard library types but has no scriptc lowering yet - - 5 | emitter.on(`topic:${suffix}`, () => {}); - 6 | emitter.listeners("topic:x"); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 7 | - - hint: a listener of this event uses checked-dynamic dispatch, so the listener array has no one static element type — listenerCount(name) counts \ No newline at end of file diff --git a/tests/harness/corpus-costs/emitter-computed-name-listeners.ts.json b/tests/harness/corpus-costs/emitter-computed-name-listeners.ts.json new file mode 100644 index 000000000..736e63575 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-computed-name-listeners.ts.json @@ -0,0 +1 @@ +{ "plain": 4200, "san": 7100 } diff --git a/tests/harness/corpus-costs/emitter-listeners-dynamic-typed.ts.json b/tests/harness/corpus-costs/emitter-listeners-dynamic-typed.ts.json new file mode 100644 index 000000000..ee5a3fc43 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-listeners-dynamic-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 7300 } diff --git a/tests/harness/corpus-costs/emitter-listeners-dynamic.cjs.json b/tests/harness/corpus-costs/emitter-listeners-dynamic.cjs.json new file mode 100644 index 000000000..424aeb55c --- /dev/null +++ b/tests/harness/corpus-costs/emitter-listeners-dynamic.cjs.json @@ -0,0 +1 @@ +{ "plain": 6300, "san": 8700 } diff --git a/tests/harness/corpus-costs/emitter-meta-listeners-dynamic.cjs.json b/tests/harness/corpus-costs/emitter-meta-listeners-dynamic.cjs.json new file mode 100644 index 000000000..62bba9734 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-meta-listeners-dynamic.cjs.json @@ -0,0 +1 @@ +{ "plain": 4800, "san": 7700 } diff --git a/tests/harness/corpus-costs/emitter-unhandled-error-payloads-typed.ts.json b/tests/harness/corpus-costs/emitter-unhandled-error-payloads-typed.ts.json new file mode 100644 index 000000000..fd922e646 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-unhandled-error-payloads-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 4900, "san": 8500 } diff --git a/tests/harness/corpus-costs/emitter-unhandled-error-payloads.cjs.json b/tests/harness/corpus-costs/emitter-unhandled-error-payloads.cjs.json new file mode 100644 index 000000000..197f0a911 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-unhandled-error-payloads.cjs.json @@ -0,0 +1 @@ +{ "plain": 5300, "san": 8800 } From 7943812231efd95688c86a38ce6df7c403ebc007 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 19:47:53 -0500 Subject: [PATCH 166/193] Keep omitted optional fields in the partial view's layout; save the receiver A partial record assertion's literal now keeps the asserted type's optional fields it omits, in their absent state, as an ordinary record of that type does: a read yields undefined, in answers false and a write adds the property, with no guard. Only an omitted required field is missing from the view, and only its writes are guarded, so a write to an absent optional field of an ordinary record of the same type no longer throws. The receiver of a guarded write is saved whenever its value is, so a right side that reassigns the receiver's variable no longer redirects the check and the store. --- .../frontend/lowering/expressions/updates.ts | 11 ++- .../frontend/lowering/partial-assertions.ts | 88 ++++++++++--------- .../record-partial-assertion-lowering.test.ts | 62 +++++++++++-- tests/corpus/record-partial-assertion.ts | 14 +++ 4 files changed, 124 insertions(+), 51 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 30ea81ae2..789fb4b25 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -908,13 +908,16 @@ export function fieldSetStmt( const refused = partialFieldWriteError(lowerer, target.shapeId, target.field, loc); if (refused !== null) { const obj = target.obj; - const stable = isSafeToRepeat(obj) - ? null - : lowerer.declareHiddenLocal("%partialWrite", obj.type); - const ref = stable ? varRef(stable.id, obj.type, loc) : obj; const saved = isSafeToRepeat(value) ? null : lowerer.declareHiddenLocal("%partialWriteValue", value.type); + // The receiver is saved whenever the value is: a right side can + // reassign the variable the receiver reads. + const stable = + isSafeToRepeat(obj) && saved === null + ? null + : lowerer.declareHiddenLocal("%partialWrite", obj.type); + const ref = stable ? varRef(stable.id, obj.type, loc) : obj; const written = saved ? varRef(saved.id, value.type, loc) : value; return { kind: "block", diff --git a/packages/compiler/src/frontend/lowering/partial-assertions.ts b/packages/compiler/src/frontend/lowering/partial-assertions.ts index 5f8ecc253..545f4b26b 100644 --- a/packages/compiler/src/frontend/lowering/partial-assertions.ts +++ b/packages/compiler/src/frontend/lowering/partial-assertions.ts @@ -4,19 +4,20 @@ * type may require more. Node erases the assertion: the value keeps the * literal's properties, and reading a field it never had yields undefined. * A record type has a fixed layout, so scriptc keeps the LITERAL's layout - * (shape A — the written fields, with the asserted type's field types) and + * (shape A — the written fields and the asserted type's optional fields, + * these in their absent state, with the asserted type's field types) and * hands the asserted type B a PARTIAL record view of it (record-views.ts): - * the value is a B whose record-source entry names A, and B fields A does - * not declare are absent. Reflection through the view is exact. + * the value is a B whose record-source entry names A, and the required B + * fields A does not declare are absent. Reflection through the view is + * exact. * * This module finds those sites and answers, per target shape, which - * fields some asserted literal leaves unwritten. The lowering consults it - * for member reads and writes, destructuring and `in`, so a read of an - * absent optional field yields undefined, as in Node, and a read of an - * absent required field throws the catchable TypeError (Node yields - * undefined — a documented divergence), instead of reaching a slot the - * view does not have. Computed once for the whole program, on first use, - * like the untyped-collection inference (js-collection-types.ts). */ + * required fields some asserted literal leaves unwritten. The lowering + * consults it for member reads and writes, destructuring and `in`, so a + * read of such a field throws the catchable TypeError (Node yields + * undefined — a documented divergence) instead of reaching a slot the view + * does not have. Computed once for the whole program, on first use, like + * the untyped-collection inference (js-collection-types.ts). */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; @@ -37,6 +38,9 @@ import { export interface PartialAssertionSite { readonly target: IrType & { kind: "record" }; readonly keys: readonly string[]; + /** The optional B fields the literal does not write, which the literal's + * layout keeps in their absent state. */ + readonly absent: readonly string[]; readonly missing: ReadonlySet; /** The asserted type's source spelling, for the throw messages. */ readonly text: string; @@ -49,7 +53,7 @@ interface PartialTarget { const NO_FIELDS: ReadonlySet = new Set(); -/** The fields some asserted literal leaves unwritten on shapes of +/** The required fields some asserted literal leaves unwritten on shapes of * `shapeId`; empty when no partial assertion produces that shape. */ export function partialAssertionFields(lowerer: Lowerer, shapeId: string): ReadonlySet { return partialTargetOf(lowerer, shapeId)?.missing ?? NO_FIELDS; @@ -109,26 +113,27 @@ export function partialAssertionSite( // map, which a view has no slots for. if (!shape.fields.some((f) => f.name === key)) return null; } - let required = false; + const absent: string[] = []; for (const field of shape.fields) { if (keys.includes(field.name)) continue; - // The view has no slot for any field the literal does not write, an - // optional one included. + // An omitted optional field keeps a slot in the literal's layout, in its + // absent state (lowerPartialAssertion), as an ordinary record of the + // asserted type does: reads yield undefined, `in` answers false and a + // write adds the property. Only a required field can be missing. + if (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0) { + absent.push(field.name); + continue; + } missing.add(field.name); - if (!(field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0)) - required = true; } - // Omitting only optional fields needs no view: the literal at the - // asserted type's layout holds their absent state. - if (!required) return null; - return { target, keys, missing, text: lowerer.checker.typeToString(targetTs) }; + if (missing.size === 0) return null; + return { target, keys, absent, missing, text: lowerer.checker.typeToString(targetTs) }; } /** The checked read of `field` through a value of `shapeId` that a partial - * assertion may have produced (undefined for an absent optional field, a - * TypeError for an absent required one), or null when the field is always - * present. `obj` is read twice (the presence test and the read), so it must - * be safe to repeat. */ + * assertion may have produced, or null when the field is always present. + * `obj` is read twice (the presence test and the read), so it must be safe + * to repeat. */ export function partialFieldRead( lowerer: Lowerer, obj: IrExpr, @@ -139,25 +144,17 @@ export function partialFieldRead( ): IrExpr | null { const target = partialTargetOf(lowerer, shapeId); if (target === null || !target.missing.has(field)) return null; - // An optional field the literal omits reads as undefined, as in Node. - const optional = - fieldType.kind === "union" && lowerer.armTag(fieldType.unionId, UNDEFINED_T) >= 0; return { kind: "ternary", cond: { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }, then: { kind: "recordGet", obj, shapeId, field, type: fieldType, loc }, - else_: optional - ? lowerer.coerceToExpected( - { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, - fieldType, - ) - : nodeThrowExpr( - 1, - "", - `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, - fieldType, - loc, - ), + else_: nodeThrowExpr( + 1, + "", + `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`, + fieldType, + loc, + ), type: fieldType, loc, }; @@ -221,15 +218,24 @@ export function lowerPartialAssertion( const value = lowerer.coerceInto(prop, lowerer.lowerExprExpecting(initializer, type), type); return { name: key, type, value }; }); + for (const name of site.absent) { + const type = shape.fields.find((f) => f.name === name)!.type as IrType & { kind: "union" }; + fields.push({ + name, + type, + value: { kind: "fieldAbsent", unionId: type.unionId, type, loc }, + }); + } // A's identity is its name-sorted field list; its DECLARED order is the - // literal's, which JSON and Object.keys read through the view's capsule. + // literal's, which JSON and Object.keys read through the view's capsule, + // then the absent optional fields, which a later write appends. const shapeId = lowerer.shapes.intern( [...fields] .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) .map((f) => ({ name: f.name, type: f.type })), false, undefined, - [...site.keys], + [...site.keys, ...site.absent], ); const built: IrExpr = { kind: "recordLit", diff --git a/packages/compiler/test/record-partial-assertion-lowering.test.ts b/packages/compiler/test/record-partial-assertion-lowering.test.ts index 0ba5fb70d..3668031f0 100644 --- a/packages/compiler/test/record-partial-assertion-lowering.test.ts +++ b/packages/compiler/test/record-partial-assertion-lowering.test.ts @@ -115,11 +115,11 @@ test("a write of a field a partial assertion never wrote throws before storing", ]); }); -test("a read of an optional field a partial assertion omits yields undefined", async () => { +test("an optional field a partial assertion omits keeps an absent slot, read without a check", async () => { const nodes = await moduleNodes<{ kind: string; cond?: { kind: string; field?: string }; - else_?: { kind: string; fn?: string }; + fields?: { name: string; value: { kind: string } }[]; }>( `interface N { content: string; note?: string } function read(value: N): string | undefined { return value.note; } @@ -128,11 +128,17 @@ test("a read of an optional field a partial assertion omits yields undefined", a export {}; `, ); - const guard = nodes.find( - (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "note", + // The literal's layout holds the omitted optional field in its absent + // state, as an ordinary record of the asserted type does. + const literal = nodes.find( + (n) => n.kind === "recordLit" && n.fields?.some((f) => f.name === "note") === true, ); - expect(guard).toBeDefined(); - expect(guard!.else_?.fn).not.toBe("error.nodeThrow"); + expect(literal?.fields?.find((f) => f.name === "note")?.value.kind).toBe("fieldAbsent"); + expect( + nodes.some( + (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "note", + ), + ).toBe(false); }); test("a write of a field a partial assertion never wrote evaluates the value before the check", async () => { @@ -163,3 +169,47 @@ test("a write of a field a partial assertion never wrote evaluates the value bef expect(saved).toBeGreaterThanOrEqual(0); expect(saved).toBeLessThan(check); }); + +test("a write of an optional field a partial assertion omits needs no guard", async () => { + const nodes = await moduleNodes<{ kind: string; cond?: { kind: string; field?: string } }>( + `interface N { content: string; note?: string } + function write(target: N): void { target.note = "x"; } + write({} as N); + write({ content: "c" }); + export {}; + `, + ); + expect( + nodes.some((n) => n.kind === "if" && n.cond?.kind === "recordHas" && n.cond.field === "note"), + ).toBe(false); +}); + +test("a partial-field write saves its receiver when the right side may reassign it", async () => { + const nodes = await moduleNodes<{ + kind: string; + body?: { kind: string; localId?: string; cond?: { kind: string; field?: string } }[]; + }>( + `${TR}function value(): string { return "x"; } + let target: TR = { truncated: true } as TR; + target.content = value(); + export {}; + `, + ); + const body = nodes + .filter((n) => n.kind === "block") + .map((n) => n.body ?? []) + .find((stmts) => + stmts.some( + (s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content", + ), + ); + expect(body).toBeDefined(); + const receiver = body!.findIndex( + (s) => s.kind === "varDecl" && /%partialWrite(?!Value)/.test(s.localId ?? ""), + ); + const value = body!.findIndex( + (s) => s.kind === "varDecl" && s.localId?.includes("partialWriteValue") === true, + ); + expect(receiver).toBeGreaterThanOrEqual(0); + expect(receiver).toBeLessThan(value); +}); diff --git a/tests/corpus/record-partial-assertion.ts b/tests/corpus/record-partial-assertion.ts index e991299c4..8acc1b344 100644 --- a/tests/corpus/record-partial-assertion.ts +++ b/tests/corpus/record-partial-assertion.ts @@ -48,4 +48,18 @@ try { order.push("caught"); } console.log(order[0]); + +// An ordinary record of the asserted type keeps a slot for its absent +// optional field, so a write adds the property. +const plainNote: Note = { content: "c", truncated: false }; +plainNote.note = "added"; +console.log(plainNote.note, "note" in plainNote, JSON.stringify(plainNote)); + +// A right side that reassigns the receiver's variable: the original +// receiver takes the write. +let reassigned: Note = { content: "first", truncated: false, note: "n" }; +const firstNote = reassigned; +const otherNote: Note = { content: "other", truncated: true, note: "o" }; +reassigned.content = ((reassigned = otherNote), "changed"); +console.log(firstNote.content, otherNote.content, reassigned === otherNote); export {}; From 0af135108b7de09f97850635c9b89af71eccacd5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 19:52:06 -0500 Subject: [PATCH 167/193] Emit each loop once in the body a parameter version leaves --- .../src/backend/llvm/emit-function.ts | 2 +- .../src/backend/llvm/emit-statements.ts | 9 +++ .../compiler/src/backend/llvm/loop-state.ts | 11 ++- packages/compiler/src/ir/ir.ts | 5 ++ .../compiler/src/ir/parameter-versions.ts | 4 +- .../test/parameter-version-emission.test.ts | 21 ++++++ .../loop-bound-parameter-plain-loops.ts.json | 6 ++ .../loop-bound-parameter-plain-loops.ts | 74 +++++++++++++++++++ 8 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json create mode 100644 tests/corpus/loop-bound-parameter-plain-loops.ts diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index aab4459e0..8bebafc87 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -148,7 +148,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { emitter.integerTempsEnabled = emitter.debug === null && !fn.async && !fn.generator; beginFunctionLoops(emitter, fn, numericFn); emitter.speculativeLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled; - emitter.loopCopiesEnabled = true; + emitter.loopCopiesEnabled = fn.plainLoops !== true; emitter.speculativeFunction = fn.name; emitter.speculation = null; emitter.speculationResume = null; diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 5eed52dce..e72afdbf0 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -1147,6 +1147,14 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { (countedLoop?.guarded || pairedLoop?.guarded) && !LOOPS.of(emitter).provenCountedGuards.has(s) ) { + // A function that emits each loop once takes only the copy without + // the integer form, which needs no safety test. + if (LOOPS.of(emitter).plainLoops) { + LOOPS.of(emitter).countedLoopsEnabled = false; + emitSlowCopy(emitter, s); + LOOPS.of(emitter).countedLoopsEnabled = true; + break; + } const safe = countedLoop ? emitCountedLoopSafety(emitter, countedLoop) : emitPairedLoopSafety(emitter, pairedLoop!); @@ -1318,6 +1326,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { // longer runs take the loop as it is emitted otherwise. const shortRuns: { stride: number; left: () => string } | null = !innermost || + LOOPS.of(emitter).plainLoops || bounded || capturedLets.length > 0 || !emitter.workerLoopPolls(s) || diff --git a/packages/compiler/src/backend/llvm/loop-state.ts b/packages/compiler/src/backend/llvm/loop-state.ts index be33ae517..e044b09bc 100644 --- a/packages/compiler/src/backend/llvm/loop-state.ts +++ b/packages/compiler/src/backend/llvm/loop-state.ts @@ -27,10 +27,11 @@ export function beginFunctionLoops( loops.unaliasedArrays = findUnaliasedLocalArrays(fn); loops.countedLoopsEnabled = emitter.debug === null && !fn.async && !fn.generator; loops.byteWindowsEnabled = loops.countedLoopsEnabled; - loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled; + loops.plainLoops = fn.plainLoops === true; + loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled && !loops.plainLoops; loops.bytesLoopEntries = null; loops.bytesLoopFunction = fn; - loops.denseLoopsEnabled = loops.countedLoopsEnabled; + loops.denseLoopsEnabled = loops.countedLoopsEnabled && !loops.plainLoops; loops.denseLoop = null; loops.denseLoopEntries = loops.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array") @@ -65,8 +66,11 @@ export interface LoopState { byteWindowsEnabled: boolean; byteWindowEntries: ReturnType; /** Typed-array loop versioning (bytes-loop-versions.ts) for the current - * function: off in debug and suspending functions and inside a copy. */ + * function: off in debug and suspending functions, in a function that + * emits each loop once and inside a copy. */ bytesLoopVersionsEnabled: boolean; + /** The current function emits each loop once (IrFunction.plainLoops). */ + plainLoops: boolean; /** Loops being versioned around the statement being emitted: a loop is * never versioned inside its own copies, and versions nest boundedly. */ readonly bytesLoopVersioning: Set; @@ -89,6 +93,7 @@ export const LOOPS = defineModuleState(() => ({ byteWindowsEnabled: true, byteWindowEntries: new Map(), bytesLoopVersionsEnabled: false, + plainLoops: false, bytesLoopVersioning: new Set(), bytesLoopEntries: null, bytesLoopFunction: null, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index c58d287fa..a49ef234e 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2000,6 +2000,11 @@ export interface IrFunction { * `index`: a computed read `(value, key)` whose callers pass a number * converted to a string as the key. */ fastRead?: "index"; + /** The body of a function whose parameter version takes its calls with + * int32 arguments (parameter-versions.ts). It runs for other arguments + * only, so the backend emits each of its loops once, as in the copy a + * failed version guard runs. */ + plainLoops?: true; /** Async: the body runs on a fiber; `returnType` is the INNER type T (a * `return v` fulfills with v) while ordinary call sites receive * Promise. With `generator` present, call sites receive the lazy diff --git a/packages/compiler/src/ir/parameter-versions.ts b/packages/compiler/src/ir/parameter-versions.ts index ca2958585..0732baf61 100644 --- a/packages/compiler/src/ir/parameter-versions.ts +++ b/packages/compiler/src/ir/parameter-versions.ts @@ -171,7 +171,8 @@ function loopBounds(fn: IrFunction, functions: ReadonlyMap): * -0 (`1 / p` is not -Infinity). The copy then receives `p | 0`, which is * the same double, so it computes what the body would; its only caller * proves the parameter an int32, so it gets the i32 entry. Other values - * run the body as before. + * run the body as before, whose loops the backend then emits once + * (`plainLoops`). * * Only plain synchronous functions qualify: not class members, no * captures, not the module entry, not dynamic-tier, at most @@ -301,6 +302,7 @@ export function versionLoopBoundParameters( : [{ kind: "return", value: call, loc }]; replaced.set(fn, { ...fn, + plainLoops: true, body: [{ kind: "if", cond: test!, then: run, else_: null, loc }, ...fn.body], }); } diff --git a/packages/compiler/test/parameter-version-emission.test.ts b/packages/compiler/test/parameter-version-emission.test.ts index eb6daff56..77c9d1a2f 100644 --- a/packages/compiler/test/parameter-version-emission.test.ts +++ b/packages/compiler/test/parameter-version-emission.test.ts @@ -65,6 +65,27 @@ test("a loop-bounding parameter some caller passes unproven gets an int32 versio expect(body).toMatch(/fdiv double 0x3FF0000000000000, %\S+/); }); +test("the body a version leaves emits each loop once", async () => { + const llvm = await emitLlvm(` +const fact: number[] = []; +function fillFact(n: number): void { + fact[0] = 1; + for (let i = 1; i <= n; i++) fact[i] = fact[i - 1]! * i; +} +fillFact(Number(process.argv.length > 5 ? "7.5" : "6")); +console.log(fact.join(" ")); +`); + expect(versions(llvm)).toEqual(["fillFact"]); + const loopBodies = (name: string): number => { + const body = new RegExp(`^define [^@]*@sc_\\w+?_${name}\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; + return body.match(/^loop\.b\d+:/gm)?.length ?? 0; + }; + // The body runs only for numbers the version does not take: its loop gets + // no speculative integer copy beside the ordinary one. + expect(loopBodies("fillFact")).toBe(1); + expect(loopBodies("fillFact_int32Params\\.i32")).toBe(1); +}); + test("a module that reads stacks keeps a single body", async () => { const llvm = await emitLlvm(`${loops}\nconsole.log(typeof new Error("x").stack);\n`); expect(versions(llvm)).toEqual([]); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json new file mode 100644 index 000000000..a148a964e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/loop-bound-parameter-plain-loops.ts" + ], + "diags": [] +} diff --git a/tests/corpus/loop-bound-parameter-plain-loops.ts b/tests/corpus/loop-bound-parameter-plain-loops.ts new file mode 100644 index 000000000..fa9e38bad --- /dev/null +++ b/tests/corpus/loop-bound-parameter-plain-loops.ts @@ -0,0 +1,74 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Functions whose count parameters bound array loops: nested, counted, +// two-counter and dense array loops, called from the main thread and a +// worker with counts no caller proves to be int32s: integers, fractions, +// NaN, -0, negatives and runs longer than a worker loop's unpolled run. +// Counts past an array's end read undefined. + +const LENGTH = 40000; + +function rowSums(u: number[], v: number[], n: number, rows: number): void { + for (let i = 0; i < rows; i++) { + let t = 0; + for (let j = 0; j < n; j++) t += u[j]! / (i + j + 1); + v[i] = t; + } +} + +function scale(src: number[], dst: number[], n: number, k: number): number { + let sum = 0; + for (let i = 0; i < n && i < dst.length; i++) { + dst[i] = (src[i]! * k) | 0; + sum += dst[i]!; + } + return sum; +} + +function reverseRange(values: number[], n: number): number { + for (let lo = 0, hi = (n | 0) - 1; lo < hi; lo++, hi--) { + const t = values[lo]!; + values[lo] = values[hi]!; + values[hi] = t; + } + let check = 0; + for (let i = 0; i < 40; i++) check = (check * 31 + values[i]!) % 1000003; + return check; +} + +function countSevens(values: number[], n: number, step: number): number { + let seen = 0; + for (let i = 0; i < n; i += step) if (values[i] === 7) seen++; + return seen; +} + +function show(value: number): string { + return Object.is(value, -0) ? "-0" : Number.isInteger(value) ? String(value) : value.toFixed(6); +} + +function run(label: string, n: number): string { + const u: number[] = []; + const ints: number[] = []; + for (let i = 0; i < LENGTH; i++) { + u.push(1 + (i % 13) * 0.25); + ints.push((i % 101) * 7 - 300); + } + const v = [0, 0, 0, 0]; + const out = new Array(LENGTH).fill(0); + rowSums(u, v, n, 4); + const scaled = scale(ints, out, n, 3); + const reversed = reverseRange(ints.map((x) => x % 10), n); + const sevens = countSevens(ints.map((x) => (x + 300) % 11), n, 3); + return `${label} ${show(n)}: rows ${v.map(show).join(" ")}; scaled ${scaled}; reversed ${reversed}; sevens ${sevens}`; +} + +const counts: number[] = JSON.parse("[12, 12.5, 0, -0, -3, 7.25, 16385, 39999.5, 40000.5, 50000]"); +counts.push(NaN, Number("9")); + +if (isMainThread) { + for (const n of counts) console.log(run("main", n)); + const worker = new Worker(new URL(import.meta.url), { workerData: counts }); + worker.on("message", (line: string) => console.log(line)); +} else { + for (const n of workerData as number[]) parentPort!.postMessage(run("worker", n)); +} From 82f5aa96086ebf9d3b33437646a973a8cf6f675b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:21:41 -0500 Subject: [PATCH 168/193] Keep x86-64-v3 code from running slower than the baseline - Worker programs run strip-mined typed-array loops in blocks of 128 iterations when the program's code is compiled for x86-64-v3 or above, or with --cpu-dispatch. At that level the vectorized loop over bytes takes 128 iterations a step, so in blocks of 64 its vector code never ran. - In --cpu-dispatch builds, the LLVM helper gives an x86-64-v3 copy the optimized code of its baseline copy when the v3 code has vectors of at most 128 bits, none wider, and no rounding or bit-count intrinsic in a loop. Its direct calls go to the callees' x86-64-v3 copies. The text partitioner places each x86-64-v3 copy with its baseline copy, so the two are optimized in one partition. --- docs/content/docs/cli.mdx | 2 + native/llvm-codegen/CMakeLists.txt | 1 + native/llvm-codegen/README.md | 2 + native/llvm-codegen/src/cpu-copies.cpp | 153 ++++++++++++++++++ native/llvm-codegen/src/cpu-copies.h | 27 ++++ native/llvm-codegen/src/emit.cpp | 10 ++ native/llvm-codegen/src/partition.cpp | 24 ++- .../compiler/src/backend/llvm/cpu-dispatch.ts | 11 +- .../src/backend/llvm/emit-statements.ts | 45 +++--- .../test/counted-loop-emission.test.ts | 25 ++- .../test/cpu-dispatch-emission.test.ts | 6 +- .../compiler/test/native-partitions.test.ts | 100 ++++++++++++ 12 files changed, 376 insertions(+), 30 deletions(-) create mode 100644 native/llvm-codegen/src/cpu-copies.cpp create mode 100644 native/llvm-codegen/src/cpu-copies.h diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index fc3ae2790..4470bad77 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -373,6 +373,8 @@ $ scriptc build app.ts --optimization=speed --cpu-dispatch -o app The second copy makes the executable larger and the first build of the program slower, since LLVM compiles the program's code twice. Unchanged rebuilds are not affected. Both copies compute the same results: neither contracts floating-point operations into fused multiply-adds. +A function's `x86-64-v3` copy runs its baseline code when its optimized code has vectors of at most 128 bits, none wider, and no rounding or bit counting in a loop. In such code, `x86-64-v3` only lets LLVM's vectorizers move scalar work onto vector instructions, which can run slower. Its calls still go to the `x86-64-v3` copies of the functions it calls. + The `SCRIPTC_CPU_LEVEL` environment variable caps the level the executable uses, for example to test the baseline code on a newer machine. It takes a level from `0` to `4`, `baseline`, or a level name such as `x86-64-v3`; any other value selects the baseline code. ```console diff --git a/native/llvm-codegen/CMakeLists.txt b/native/llvm-codegen/CMakeLists.txt index fc4c07210..14263ad79 100644 --- a/native/llvm-codegen/CMakeLists.txt +++ b/native/llvm-codegen/CMakeLists.txt @@ -84,6 +84,7 @@ endif() add_executable(scriptc-llvm-codegen src/cache.cpp + src/cpu-copies.cpp src/diagnostics.cpp src/emit.cpp src/main.cpp diff --git a/native/llvm-codegen/README.md b/native/llvm-codegen/README.md index 7305db21d..f0afcfd26 100644 --- a/native/llvm-codegen/README.md +++ b/native/llvm-codegen/README.md @@ -37,6 +37,8 @@ Repeating `--output` divides the program into that many partitions, which are co With `--split codegen`, several outputs divide only code generation. The helper optimizes the module whole, as it does for a single output, then divides its functions in module order into runs of about the same instruction count, one per output, and generates code for every output on its own thread. Linking the outputs in order lays the functions out as one output would. A local definition that another output uses becomes hidden so the objects can reference each other; the others stay local. Every output after the first is generated from a copy of the optimized module read back from bitcode with its use-list order, so each function's machine code matches a single output except for jumps to definitions that another output also uses. When one run would hold more than three quarters of the module's instructions, the first output holds the whole module and the other outputs are empty objects. Placement depends only on the optimized module and the output count. `--split codegen` requires an optimized build and no `--cache-dir`; scriptc passes it for optimized executables with more than 128 KiB of LLVM text that stay one partition. +In a `--cpu-dispatch` module, each function has a baseline copy and an x86-64-v3 copy marked `scriptc-cpu-copy`. Partitioning places each x86-64-v3 copy with its baseline copy. After optimization, an x86-64-v3 copy whose code has vectors of at most 128 bits, none wider, and no rounding or bit-count intrinsic in a loop takes its baseline copy's optimized code, and its calls to baseline copies call their x86-64-v3 copies (`src/cpu-copies.h`). + WebAssembly code generation differs from LLVM's default pipeline in two ways that do not change its output. The pass that gives multiple-entry loops a single entry runs only on functions whose control flow is irreducible; its reachability analysis grows with the square of a function's block count even when it has nothing to change. Object emission also leaves out the module pass that collects external symbols for assembly declarations, so each function's machine code is freed once it is printed instead of when the whole module is compiled. `--cache-dir ` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect. Partitions drop the names of their struct types before the thin link, which would otherwise number same-named types from different partitions in an order that any edit can shift, changing the module after importing in partitions the edit did not touch. diff --git a/native/llvm-codegen/src/cpu-copies.cpp b/native/llvm-codegen/src/cpu-copies.cpp new file mode 100644 index 000000000..d1184aa10 --- /dev/null +++ b/native/llvm-codegen/src/cpu-copies.cpp @@ -0,0 +1,153 @@ +#include "cpu-copies.h" + +#include "llvm/ADT/STLExtras.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/StringExtras.h" +#include "llvm/ADT/StringMap.h" +#include "llvm/Analysis/LoopInfo.h" +#include "llvm/IR/DerivedTypes.h" +#include "llvm/IR/Dominators.h" +#include "llvm/IR/Function.h" +#include "llvm/IR/InstrTypes.h" +#include "llvm/IR/Instructions.h" +#include "llvm/IR/IntrinsicInst.h" +#include "llvm/IR/Intrinsics.h" +#include "llvm/Transforms/Utils/Cloning.h" +#include "llvm/Transforms/Utils/ValueMapper.h" + +#include +#include +#include +#include +#include + +using namespace llvm; + +namespace scriptc { +namespace { + +// A function's name without the `.llvm.` suffix of the thin link. +StringRef stem(StringRef Name) { + size_t At = Name.rfind(".llvm."); + if (At == StringRef::npos) + return Name; + StringRef Suffix = Name.drop_front(At + 6); + if (Suffix.empty() || !all_of(Suffix, isDigit)) + return Name; + return Name.take_front(At); +} + +// The size of a vector type in bits, or 0 for another type. +uint64_t vectorBits(Type *T) { + auto *Vector = dyn_cast(T); + return Vector ? Vector->getPrimitiveSizeInBits().getFixedValue() : 0; +} + +// Whether x86-64-v3 changes only the body's vector code of at most 128 bits: +// the body has such vectors, and nothing that the level compiles to faster +// code than the baseline, which is vectors of 256 bits (AVX2), and in a +// loop, rounding (SSE4.1; the baseline calls the C library) and bit counts +// (POPCNT, LZCNT, TZCNT). Scalar code keeps its x86-64-v3 code, whose VEX +// encodings need fewer register copies. +bool onlyNarrowVectors(Function &F) { + DominatorTree Dominators(F); + LoopInfo Loops(Dominators); + bool Narrow = false; + for (const BasicBlock &Block : F) + for (const Instruction &I : Block) { + uint64_t Bits = vectorBits(I.getType()); + for (const Use &Operand : I.operands()) + Bits = std::max(Bits, vectorBits(Operand->getType())); + if (Bits > 128) + return false; + Narrow |= Bits != 0; + const auto *Call = dyn_cast(&I); + if (!Call || !Loops.getLoopFor(&Block)) + continue; + switch (Call->getIntrinsicID()) { + case Intrinsic::floor: + case Intrinsic::ceil: + case Intrinsic::trunc: + case Intrinsic::rint: + case Intrinsic::nearbyint: + case Intrinsic::round: + case Intrinsic::roundeven: + case Intrinsic::ctpop: + case Intrinsic::ctlz: + case Intrinsic::cttz: + return false; + default: + break; + } + } + return Narrow; +} + +// Replaces the body of Copy with a clone of Base's. The clone's direct calls +// to baseline copies call the matching x86-64-v3 copies in Tier. +void useBaselineBody(Function &Copy, Function &Base, + const StringMap &Tier) { + GlobalValue::LinkageTypes Linkage = Copy.getLinkage(); + GlobalValue::VisibilityTypes Visibility = Copy.getVisibility(); + GlobalValue::UnnamedAddr UnnamedAddr = Copy.getUnnamedAddr(); + Copy.deleteBody(); + ValueToValueMapTy Map; + for (auto [From, To] : zip(Base.args(), Copy.args())) + Map[&From] = &To; + SmallVector Returns; + CloneFunctionInto(&Copy, &Base, Map, + CloneFunctionChangeType::LocalChangesOnly, Returns); + Copy.setLinkage(Linkage); + Copy.setVisibility(Visibility); + Copy.setUnnamedAddr(UnnamedAddr); + for (BasicBlock &Block : Copy) + for (Instruction &I : Block) { + auto *Call = dyn_cast(&I); + Function *Callee = Call ? Call->getCalledFunction() : nullptr; + if (!Callee) + continue; + StringRef Name = stem(Callee->getName()); + if (!Name.consume_back(".base")) + continue; + auto It = Tier.find((Name + ".v3").str()); + if (It != Tier.end() && + It->second->getFunctionType() == Callee->getFunctionType()) + Call->setCalledFunction(It->second); + } +} + +} // namespace + +void useBaselineBodies(Module &M) { + // Every function by its stem, so the copies of one function find each + // other whichever of them the thin link renamed. + StringMap Functions; + bool HasCopies = false; + for (Function &F : M) { + Functions[stem(F.getName())] = &F; + HasCopies |= F.hasFnAttribute(CpuCopyAttribute); + } + if (!HasCopies) + return; + std::vector> Replaced; + for (Function &F : M) { + if (F.isDeclaration() || !F.hasFnAttribute(CpuCopyAttribute)) + continue; + StringRef Name = stem(F.getName()); + if (!Name.consume_back(".v3")) + continue; + auto It = Functions.find((Name + ".base").str()); + if (It == Functions.end()) + continue; + Function *Base = It->second; + if (Base->isDeclaration() || Base->hasAvailableExternallyLinkage() || + Base->getFunctionType() != F.getFunctionType() || + !onlyNarrowVectors(F)) + continue; + Replaced.emplace_back(&F, Base); + } + for (auto [Copy, Base] : Replaced) + useBaselineBody(*Copy, *Base, Functions); +} + +} // namespace scriptc diff --git a/native/llvm-codegen/src/cpu-copies.h b/native/llvm-codegen/src/cpu-copies.h new file mode 100644 index 000000000..29c155da6 --- /dev/null +++ b/native/llvm-codegen/src/cpu-copies.h @@ -0,0 +1,27 @@ +#pragma once + +#include "llvm/IR/Module.h" + +namespace scriptc { + +// The string attribute that marks the x86-64-v3 copies of a `--cpu-dispatch` +// build. The compiler names a function's copies `.base` and +// `.v3`; the thin link may rename either to `.llvm.`. +inline constexpr const char *CpuCopyAttribute = "scriptc-cpu-copy"; + +// Runs after optimization, before code generation. An x86-64-v3 copy whose +// optimized body has vectors of at most 128 bits takes the optimized body of +// its baseline copy instead, unless it has something else the baseline +// compiles to slower code: a vector wider than 128 bits, or a rounding or +// bit-count intrinsic in a loop. In 128-bit vector code the higher level +// only adds instructions that the vectorizers prefer over scalar ones, and +// these can run slower. Scalar code keeps its x86-64-v3 code. The copy keeps +// its name, so its callers do not change, and the body's direct calls to +// baseline copies call their x86-64-v3 copies, so that callees still run at +// the level the executable chose. +// +// A copy is left as it is when its baseline copy is not defined in the +// module or the two copies' types differ. +void useBaselineBodies(llvm::Module &M); + +} // namespace scriptc diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index 52333ca59..f5136c2e5 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -1,6 +1,7 @@ #include "emit.h" #include "cache.h" +#include "cpu-copies.h" #include "diagnostics.h" #include "partition.h" #include "profile-blocks.h" @@ -825,6 +826,7 @@ emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level, P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); if (Annotating) Weigher.weigh(ProfileUse, Match); + useBaselineBodies(M); if (std::optional Failure = verify(M, "post_optimization_verification_failed")) return Failure; @@ -1158,6 +1160,13 @@ compileOptimizedPartitions(const EmitOptions &Options, CodeGeneration.run(Mod); return false; }; + else + // The backend passes the module it owns; only the hook's signature is + // const. + Config.PreCodeGenModuleHook = [](unsigned, const Module &Optimized) { + useBaselineBodies(const_cast(Optimized)); + return true; + }; // The link refers to module identifiers for its whole lifetime. std::vector Names(Count); @@ -1573,6 +1582,7 @@ emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine, } if (Annotating) Weigher.weigh(Options.ProfileUse, Match); + useBaselineBodies(M); if (std::optional Failure = verify(M, "post_optimization_verification_failed")) return Failure; diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp index 9ab1ce657..7af4681e7 100644 --- a/native/llvm-codegen/src/partition.cpp +++ b/native/llvm-codegen/src/partition.cpp @@ -824,8 +824,30 @@ std::optional partitionProgramText(StringRef Text, Symbols[Root].Kind != SymbolKind::Function) Root = I; Roots[I] = Root; + } + // The x86-64-v3 copy of a --cpu-dispatch build's function (`.v3`), + // with the chain that ends at it, is placed with the chain of its baseline + // copy (`.base`), so that the helper can compare the two copies + // after optimization (cpu-copies.h). + DenseMap CopyRoots; + for (unsigned I = 0; I < Symbols.size(); ++I) { + StringRef Name = Symbols[I].Name; + if (Symbols[I].Kind != SymbolKind::Function || !Live[I] || + Roots[I] != I || !Name.consume_back(".v3")) + continue; + auto Base = Index.find((Name + ".base").str()); + if (Base != Index.end() && Live[Base->second] && + Symbols[Base->second].Kind == SymbolKind::Function) + CopyRoots[I] = Roots[Base->second]; + } + for (unsigned I = 0; I < Symbols.size(); ++I) { + if (Symbols[I].Kind == SymbolKind::Declaration || !Live[I]) + continue; + auto Copy = CopyRoots.find(Roots[I]); + if (Copy != CopyRoots.end()) + Roots[I] = Copy->second; if (Symbols[I].Kind == SymbolKind::Function) - Symbols[I].Partition = stableHash(Symbols[Root].Name) % Count; + Symbols[I].Partition = stableHash(Symbols[Roots[I]].Name) % Count; } placeGroups(Symbols, Roots, Count); balanceRoots(Symbols, Roots, Live, Count); diff --git a/packages/compiler/src/backend/llvm/cpu-dispatch.ts b/packages/compiler/src/backend/llvm/cpu-dispatch.ts index ced356cca..a2a66479d 100644 --- a/packages/compiler/src/backend/llvm/cpu-dispatch.ts +++ b/packages/compiler/src/backend/llvm/cpu-dispatch.ts @@ -12,17 +12,18 @@ export const CPU_DISPATCH_TIERS: readonly TargetCpu[] = ["x86-64-v3"]; const LEVEL = "@sc_cpu_level"; /** The attribute groups of the x86-64-v3 copies: #0's and #2's (see - * emit-module.ts functionAttributes) with the level's `target-cpu`. */ + * emit-module.ts functionAttributes) with the level's `target-cpu`, and + * `scriptc-cpu-copy`, by which the LLVM helper finds the copies. After + * optimization, a copy whose code gains nothing from x86-64-v3 takes its + * baseline copy's code (native/llvm-codegen/src/cpu-copies.h). */ const COPY_GROUPS: ReadonlyMap = new Map([ ["0", "3"], ["2", "4"], ]); export function cpuDispatchAttributes(common: string): string[] { - return [ - `attributes #3 = {${common} "target-cpu"="x86-64-v3" }`, - `attributes #4 = { noinline${common} "target-cpu"="x86-64-v3" }`, - ]; + const copy = `"target-cpu"="x86-64-v3" "scriptc-cpu-copy"`; + return [`attributes #3 = {${common} ${copy} }`, `attributes #4 = { noinline${common} ${copy} }`]; } /** Bits of CPUID leaf 1's ECX that x86-64-v3 needs: SSE3 (0), SSSE3 (9), diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 5eed52dce..ecaf90e00 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -1278,26 +1278,26 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { } }; // A counted loop over typed arrays, or a loop to a typed array's - // length, polls between blocks of WORKER_POLL_BLOCK iterations instead + // length, polls between blocks of workerPollBlock iterations instead // of in its body: the inner loop over a block then has no exit // besides its own bound, which LLVM can vectorize. A typed // array's length is fixed, so each block can take it as its limit when // the loop does not assign the array's binding. // // Inside another loop of this function, which polls at least once - // every WORKER_POLL_BLOCK of its own iterations, a loop needs less: + // every workerPollBlock of its own iterations, a loop needs less: // - a loop whose counters' ranges bound its runs, and those of the - // loops in its body, to WORKER_POLL_BLOCK^2 needs no poll; + // loops in its body, to workerPollBlock^2 needs no poll; // - an innermost counted loop is strip-mined, typed arrays or not, // and does not poll after its last block. A run of at most - // WORKER_POLL_BLOCK iterations then never polls, and the countdown + // workerPollBlock iterations then never polls, and the countdown // leaves its body. A run of at most WORKER_UNPOLLED_RUN iterations // is one block, polled once as it leaves. + const pollBlock = workerPollBlock(emitter); const enclosed = emitter.mod.workers === true && emitter.jumpTargets.some((target) => target.kind === "loop"); - const bounded = - enclosed && boundedLoopRuns(emitter, s) <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK; + const bounded = enclosed && boundedLoopRuns(emitter, s) <= pollBlock * pollBlock; const innermost = enclosed && !containsLoop(s.body); const stripMined = !bounded && @@ -1305,7 +1305,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { integerSlot !== null && ((countedLoop !== null && countedLimit !== null && - Math.abs(countedLoop.step) * WORKER_POLL_BLOCK <= 2 ** 52) || + Math.abs(countedLoop.step) * pollBlock <= 2 ** 52) || (integerLoop !== null && integerArrayLoop === null && integerLoop.stableReceiver)) && emitter.workerLoopPolls(s) && (loopAccessesTypedArrays(s) || (countedLoop !== null && innermost)); @@ -1314,7 +1314,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { // step on each side, has a run whose length its counters fix on entry. // With a small body, a run of at most WORKER_UNPOLLED_RUN steps takes a // plain copy of the loop without a poll, which polls once as it leaves - // when it ran more than WORKER_POLL_BLOCK steps, like a strip-mined run; + // when it ran more than workerPollBlock steps, like a strip-mined run; // longer runs take the loop as it is emitted otherwise. const shortRuns: { stride: number; left: () => string } | null = !innermost || @@ -1491,7 +1491,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { emitPlainLoop(shortEnd, null); // A run longer than one block polls once as it leaves. B.startBlock(shortEnd); - B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * WORKER_POLL_BLOCK}`); + B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * pollBlock}`); B.condBr(pollsAfter, shortPoll, exit); B.startBlock(shortPoll); emitter.emitWorkerPoll(); @@ -2811,8 +2811,15 @@ function emitInt32KindLoop( /** At the top of a loop copy's header: lets a speculative loop find its * ordinary copy's header, and keeps its third copy out of the loop. */ -/** Iterations between cancellation polls of a strip-mined counted loop. */ -const WORKER_POLL_BLOCK = 64; +/** Iterations between cancellation polls of a strip-mined counted loop. A + * block holds at least one step of the vectorized loop over bytes, so that + * a full block runs vector code: 64 iterations at the baseline (16-byte + * vectors, interleaved 4 times), and 128 when the program's code is compiled + * for x86-64-v3 or above, whose vectors take 32 bytes. */ +function workerPollBlock(emitter: LlEmitter): number { + const cpu = emitter.targetCpu; + return emitter.cpuDispatch || cpu === "x86-64-v3" || cpu === "x86-64-v4" ? 128 : 64; +} /** The longest run of a strip-mined loop inside another loop that polls * only as it leaves. */ @@ -2855,7 +2862,8 @@ function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number { if (stepped && range && exactSteps(range, stepped.step)) trips = Math.floor((range.max - range.min) / Math.abs(stepped.step)) + 1; } - if (!(trips <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK)) return Infinity; + const pollBlock = workerPollBlock(emitter); + if (!(trips <= pollBlock * pollBlock)) return Infinity; let nested = 0; const visit = (s: IrStmt): boolean => { if (s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf") { @@ -2909,17 +2917,17 @@ interface StripMinedCounter { limit: () => string; } -/** A counted `for` loop of a worker program as blocks of WORKER_POLL_BLOCK +/** A counted `for` loop of a worker program as blocks of workerPollBlock * iterations, with the cancellation poll between blocks: the outer header * tests the counter against the loop's limit, each block runs the body up - * to the nearer of the limit and WORKER_POLL_BLOCK steps on, and the poll + * to the nearer of the limit and workerPollBlock steps on, and the poll * follows the block. It polls once per block, and the inner loop's only * exit besides `break` and `return` is its bound. * * With `runSlot` (for a loop that another loop of the function encloses), a * block that ends at the limit leaves the loop without polling, and a run * of at most WORKER_UNPOLLED_RUN iterations is one block, polled once as - * it leaves when it took more than WORKER_POLL_BLOCK iterations. `runSlot` + * it leaves when it took more than workerPollBlock iterations. `runSlot` * holds whether the current block is such a run. */ function emitStripMinedCountedLoop( emitter: LlEmitter, @@ -2932,6 +2940,7 @@ function emitStripMinedCountedLoop( const B = emitter.B; const { slot, type, signed, step } = counter; const ascending = step > 0; + const pollBlock = workerPollBlock(emitter); const compare = !signed ? "ult" : ascending ? "slt" : "sgt"; const saturating = `llvm.${signed ? "sadd" : "uadd"}.sat.${type}`; const nearer = `llvm.${!signed ? "umin" : ascending ? "smin" : "smax"}.${type}`; @@ -2956,9 +2965,7 @@ function emitStripMinedCountedLoop( B.startBlock(block); const stepped = B.tmp(); const bound = B.tmp(); - B.line( - `${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * WORKER_POLL_BLOCK})`, - ); + B.line(`${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * pollBlock})`); B.line(`${bound} = call ${type} @${nearer}(${type} ${stepped}, ${type} ${limit})`); if (runSlot === null) B.line(`store ${type} ${bound}, ptr ${boundSlot}`); else { @@ -2975,7 +2982,7 @@ function emitStripMinedCountedLoop( : `${left} = sub ${wrap} ${type} ${start}, ${limit}`, ); B.line(`${whole} = icmp ule ${type} ${left}, ${stride * WORKER_UNPOLLED_RUN}`); - B.line(`${long} = icmp ugt ${type} ${left}, ${stride * WORKER_POLL_BLOCK}`); + B.line(`${long} = icmp ugt ${type} ${left}, ${stride * pollBlock}`); B.line(`${polled} = and i1 ${whole}, ${long}`); B.line(`${end} = select i1 ${whole}, ${type} ${limit}, ${type} ${bound}`); B.line(`store ${type} ${end}, ptr ${boundSlot}`); diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index 30a139a81..4d41e4204 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -79,7 +79,10 @@ test("worker programs poll counted loops over typed arrays between blocks of ite "console.log(b[7], mix(8), c[0], shrink(new Uint8Array(8).fill(1)));", workers ? "if (!isMainThread) new Worker(new URL(import.meta.url));" : "", ].join("\n"); - const emit = async (workers: boolean): Promise => { + const emit = async ( + workers: boolean, + options: Parameters[1] = {}, + ): Promise => { const entry = join(dir, `${workers ? "worker" : "plain"}.ts`); await writeFile(entry, source(workers)); const outPath = join(dir, `${workers ? "worker" : "plain"}.ir.json`); @@ -89,14 +92,16 @@ test("worker programs poll counted loops over typed arrays between blocks of ite // other targets read the process-wide alert word. return emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")), { targetTriple: "x86_64-unknown-linux-gnu", + ...options, }); }; - // Every emitted version of a function (the number and int32 entries). + // Every emitted version of a function (the number and int32 entries, and + // the copies of a dispatching build). const bodies = (ll: string, name: string): string => [ ...ll.matchAll( new RegExp( - `define internal [^\\n]+ @sc_b?f_${name}(?:\\.i32)?\\([^\\n]*\\n([\\s\\S]*?)\\n\\}`, + `define internal [^\\n]+ @sc_b?f_${name}(?:\\.i32)?(?:\\.base|\\.v3)?\\([^\\n]*\\n([\\s\\S]*?)\\n\\}`, "g", ), ), @@ -125,6 +130,20 @@ test("worker programs poll counted loops over typed arrays between blocks of ite expect(codes).toMatch(/@llvm\.uadd\.sat\.i64\(i64 %\w+, i64 64\)/); expect(codes).toMatch(/@llvm\.umin\.i64\(i64 %\w+, i64 %\w+\)/); expect(codes).toMatch(/^loop\.poll\d+:$/m); + // Code for x86-64-v3 takes blocks of 128 iterations, one step of its + // vectorized loop over bytes. + const x86 = { targetTriple: "x86_64-unknown-linux-gnu" }; + for (const options of [ + { ...x86, targetCpu: "x86-64-v3" as const }, + { ...x86, targetCpu: "x86-64-v4" as const }, + { ...x86, cpuDispatch: true }, + ]) + expect(bodies(await emit(true, options), "codes"), JSON.stringify(options)).toMatch( + /@llvm\.uadd\.sat\.i64\(i64 %\w+, i64 128\)/, + ); + expect(bodies(await emit(true, { ...x86, targetCpu: "x86-64-v2" }), "codes")).toMatch( + /@llvm\.uadd\.sat\.i64\(i64 %\w+, i64 64\)/, + ); // A loop that assigns its array reads the new length on the next // iteration: it keeps the budget. const shrink = bodies(workerModule, "shrink"); diff --git a/packages/compiler/test/cpu-dispatch-emission.test.ts b/packages/compiler/test/cpu-dispatch-emission.test.ts index 43d80b335..e538e405d 100644 --- a/packages/compiler/test/cpu-dispatch-emission.test.ts +++ b/packages/compiler/test/cpu-dispatch-emission.test.ts @@ -119,10 +119,12 @@ test("a dispatching x86-64 build gives each program function a baseline and an x for (const [name] of coroutines) expect(defs.has(`${name}.v3`), name).toBe(false); // Functions used as values are referenced by their dispatching name. expect(ll).not.toMatch(/ptr @[\w.$-]+\.(v3|base)(?!\()/); - expect(ll).toMatch(/^attributes #3 = \{ sanitize_address uwtable "target-cpu"="x86-64-v3" \}$/m); + expect(ll).toMatch( + /^attributes #3 = \{ sanitize_address uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" \}$/m, + ); // Unsanitized builds leave sanitize_address out of the copies' groups too. expect(emit({ ...x86, cpuDispatch: true, sanitizeAttributes: false })).toMatch( - /^attributes #3 = \{ uwtable "target-cpu"="x86-64-v3" \}$/m, + /^attributes #3 = \{ uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" \}$/m, ); // main sets the level before anything else runs. expect(defs.get("main")).toMatch( diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index 836d1202b..344e1babe 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -593,6 +593,106 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { expect(fewGrouped.get("main")).toBe(fewPlain.get("main")); }); + test.runIf(target?.llvmTriple.startsWith("x86_64") === true)( + "an x86-64-v3 copy shares its baseline copy's partition and takes its code unless it has wider vectors", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-cpu-copies-")); + dirs.push(dir); + // Copies as --cpu-dispatch emits them: `.base` in #0 and + // `.v3` in #1. Two callers each, so that every copy is placed by + // its own name. Each f calls h in its own tier; f and h have 128-bit + // vectors, g a 256-bit vector, and s only scalar code. + const pairs = 4; + const copies = (tier: "base" | "v3", group: number): string[] => [ + `define internal void @h.${tier}(ptr %p) #${group} {`, + " %v = load <2 x double>, ptr %p", + " %w = fmul <2 x double> %v, splat (double 3.0)", + " store <2 x double> %w, ptr %p", + " ret void", + "}", + ...Array.from({ length: pairs }, (_, index) => [ + `define internal void @f${index}.${tier}(ptr %p) #${group} {`, + " %v = load <2 x double>, ptr %p", + ` %w = fadd <2 x double> %v, splat (double ${index + 1}.0)`, + " store <2 x double> %w, ptr %p", + ` call void @h.${tier}(ptr %p)`, + " ret void", + "}", + ]).flat(), + `define internal void @g.${tier}(ptr %p) #${group} {`, + " %v = load <4 x double>, ptr %p", + " %w = fadd <4 x double> %v, %v", + " store <4 x double> %w, ptr %p", + " ret void", + "}", + `define internal double @s.${tier}(double %x) #${group} {`, + " %y = fadd double %x, 1.0", + " ret double %y", + "}", + ]; + const calls = (result: string): string[] => + (["base", "v3"] as const).flatMap((tier) => [ + ...Array.from( + { length: pairs }, + (_, index) => ` call void @f${index}.${tier}(ptr @sink)`, + ), + ` call void @g.${tier}(ptr @sink)`, + ` %${result}.${tier} = call double @s.${tier}(double 1.0)`, + ]); + const source = [ + "@sink = global [4 x double] zeroinitializer", + ...copies("base", 0), + ...copies("v3", 1), + "define void @other() {", + ...calls("o"), + " ret void", + "}", + "define i32 @main() {", + ...calls("m"), + " ret i32 0", + "}", + "attributes #0 = { noinline uwtable }", + 'attributes #1 = { noinline uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" }', + ].join("\n"); + const objects = await emit(dir, source + "\n", { outputs: 8, optLevel: "2", name: "copies" }); + // Each function's object and disassembly, by its name without the + // thin link's `.llvm.` suffix. + const functions = new Map(); + for (const [index, object] of objects.entries()) { + const { stdout } = await execFileAsync("objdump", ["-dr", "--no-show-raw-insn", object]); + for (const part of stdout.split(/\n(?=[0-9a-f]+ <)/)) { + const name = /^[0-9a-f]+ <([^>]+)>:/.exec(part)?.[1]?.replace(/\.llvm\.\d+$/, ""); + if (name !== undefined) functions.set(name, { object: index, code: part }); + } + } + const at = (name: string) => { + const found = functions.get(name); + expect(found, name).toBeDefined(); + return found!; + }; + for (const stem of [ + "h", + "g", + "s", + ...Array.from({ length: pairs }, (_, index) => `f${index}`), + ]) + expect(at(`${stem}.v3`).object, stem).toBe(at(`${stem}.base`).object); + // 128-bit vectors: the v3 copies run the baseline's code (SSE2, without + // VEX encodings), and f's still calls h's v3 copy. + for (let index = 0; index < pairs; index++) { + const code = at(`f${index}.v3`).code; + expect(code).toMatch(/\saddpd\s/); + expect(code).not.toMatch(/\svaddpd\s/); + expect(code).toMatch(/[\s<]h\.v3(?:\.llvm\.\d+)?[-+>]/); + } + expect(at("h.v3").code).toMatch(/\smulpd\s/); + // A 256-bit vector keeps the v3 code, and so does scalar code. + expect(at("g.v3").code).toContain("%ymm"); + expect(at("s.v3").code).toMatch(/\svaddsd\s/); + expect(at("g.base").code).not.toContain("%ymm"); + }, + ); + test("internal definitions nothing reaches are left out of every partition", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-unreachable-")); dirs.push(dir); From f5a677f086253f3375d2ad056dd84a2355bd9310 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 18:22:56 -0700 Subject: [PATCH 169/193] Forward a configured libnode pack into test:sandbox lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lanes run with CI=1, so the Node island tests require a libnode pack and fail rather than skip without one. A lane Sandbox prepared from a snapshot that carries a pack had no way to be told where it is, because the lane environment pinned only CI, SCRIPTC_SAN and the shard variables. Forward an explicitly configured SCRIPTC_LIBNODE — a directory inside the Sandbox, never the host — through sandboxLaneEnv, which every remote lane command already uses, and document it in the runner's help. Co-Authored-By: Claude Opus 5.5 --- scripts/sandbox-platform.mjs | 8 +++++++- scripts/sandbox-test.mjs | 5 ++++- tests/harness/sandbox-platform.test.ts | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/sandbox-platform.mjs b/scripts/sandbox-platform.mjs index c3e4a34b6..7bb061ac6 100644 --- a/scripts/sandbox-platform.mjs +++ b/scripts/sandbox-platform.mjs @@ -21,12 +21,18 @@ export function sandboxHostSchedule(platform, invariantFiles) { * Sandbox image already defines these variables. Empty values restore the * unsharded, platform-native defaults; intentional remote and local shards * override them at their call sites. + * + * An explicitly configured SCRIPTC_LIBNODE is forwarded as is. It names a pack + * inside the Sandbox, never on the host, and the Node island tests fail rather + * than skip in a CI lane without one, so a lane started from a snapshot that + * carries a pack must be told where it is. */ -export function sandboxLaneEnv(lane) { +export function sandboxLaneEnv(lane, env = process.env) { return { CI: "1", SCRIPTC_PORTABLE_ONLY: "", SCRIPTC_SAN: lane === "san" ? "1" : "", SCRIPTC_TEST_SHARD: "", + SCRIPTC_LIBNODE: env.SCRIPTC_LIBNODE ?? "", }; } diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs index f2e58cb41..caf0c3a16 100644 --- a/scripts/sandbox-test.mjs +++ b/scripts/sandbox-test.mjs @@ -234,7 +234,10 @@ Environment: SCRIPTC_SANDBOX_TIMEOUT sandbox and command timeout (default: 45m) SCRIPTC_TEST_WORKERS Vitest workers per sandbox (default: 4) SCRIPTC_LOCAL_TEST_WORKERS Vitest workers per local lane (default: 2) - SCRIPTC_LOCAL_CASE_SHARDS local shards per external suite lane (default: 2)`); + SCRIPTC_LOCAL_CASE_SHARDS local shards per external suite lane (default: 2) + SCRIPTC_LIBNODE Optional libnode pack inside the Sandbox (for example + /vercel/sandbox/libnode-pack) that the lane exports, so the + Node island tests run there as they do in CI`); process.exit(0); } diff --git a/tests/harness/sandbox-platform.test.ts b/tests/harness/sandbox-platform.test.ts index 31f45865c..999c74614 100644 --- a/tests/harness/sandbox-platform.test.ts +++ b/tests/harness/sandbox-platform.test.ts @@ -34,16 +34,26 @@ test("other hosts retain native-file coverage in Linux Sandboxes", () => { }); test("lane environments override inherited sanitizer and scheduler settings", () => { - expect(sandboxLaneEnv("plain")).toEqual({ + expect(sandboxLaneEnv("plain", {})).toEqual({ CI: "1", SCRIPTC_PORTABLE_ONLY: "", SCRIPTC_SAN: "", SCRIPTC_TEST_SHARD: "", + SCRIPTC_LIBNODE: "", }); - expect(sandboxLaneEnv("san")).toEqual({ + expect(sandboxLaneEnv("san", {})).toEqual({ CI: "1", SCRIPTC_PORTABLE_ONLY: "", SCRIPTC_SAN: "1", SCRIPTC_TEST_SHARD: "", + SCRIPTC_LIBNODE: "", }); }); + +test("a lane forwards the libnode pack it is given", () => { + // The path names a pack inside the Sandbox; the island tests require one in + // a CI lane, and a snapshot-provided pack is only found through this. + const pack = "/vercel/sandbox/libnode-pack"; + expect(sandboxLaneEnv("plain", { SCRIPTC_LIBNODE: pack }).SCRIPTC_LIBNODE).toBe(pack); + expect(sandboxLaneEnv("san", { SCRIPTC_LIBNODE: pack }).SCRIPTC_LIBNODE).toBe(pack); +}); From b9f5486c9c0a005cbeb8b5868cf9455cfbae7ed9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:25:14 -0500 Subject: [PATCH 170/193] Type fields read before the constructor stores them, and key cached builds by the inference settings With SCRIPTC_INFER_JS=1, a field counts as always present only when it is defined before any code can observe the instance: construction runs from the base class down, field initializers and then the constructor body, and a statement that reads or passes on `this` (a helper called with `this`, a local holding it, a method call, a base constructor calling an overridden method) or an early `return` ends the scan. Otherwise the field's overlay type includes undefined, so `inspect(this); this.x = x` reads undefined in `inspect` as Node does. The early executable cache and the shape overlay memo now key their entries by SCRIPTC_INFER_JS and SCRIPTC_SHAPE_OVERLAY, so toggling either rebuilds instead of restoring a build made with the other setting. --- .../src/executable/executable-cache.test.ts | 4 +- .../src/executable/executable-cache.ts | 7 ++ .../src/frontend/shape-inference/analyze.ts | 84 ++++++++++++++----- .../src/frontend/shape-inference/memo.ts | 9 +- .../src/frontend/shape-inference/overlay.ts | 4 + packages/compiler/src/index.ts | 4 + .../inferred-field-before-assignment.js.json | 6 ++ .../inferred-field-before-assignment.js | 42 ++++++++++ .../inferred-field-before-assignment.js.json | 1 + tests/harness/js-inference.test.ts | 2 + 10 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json create mode 100644 tests/corpus/inferred-field-before-assignment.js create mode 100644 tests/harness/corpus-costs/inferred-field-before-assignment.js.json diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index 9c81ccc19..87103d9a3 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -221,7 +221,7 @@ test("early executable cache misses on source and resolution changes", async () expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull(); }); -test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, and FFI manifests", async () => { +test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, shape inference, and FFI manifests", async () => { const f = await fixture(); const tracker = new FrontendInputTracker(); tracker.run(() => trackedReadFile(f.source)); @@ -246,6 +246,8 @@ test("early executable keys isolate compile modes, paths, implementation, checke { ...f.options, profile: "profile-use:digest" }, { ...f.options, functionLayout: "layout-digest" }, { ...f.options, checker: "tsc-ts-executable-identity" }, + { ...f.options, shapeOverlay: true }, + { ...f.options, jsInference: true }, { ...f.options, npmStatic: "auto" }, { ...f.options, npmStatic: ["commander"] }, { ...f.options, outPath: join(f.options.outDir, "other") }, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 3a8e834a6..1cdaed830 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -204,6 +204,9 @@ export interface EarlyExecutableCacheOptions { /** Omitted unless shape inference's overlay compiles the packages * (frontend/shape-inference). */ shapeOverlay?: true; + /** Omitted unless shape inference types the program's own JavaScript + * (SCRIPTC_INFER_JS=1, frontend/js-inference.ts). */ + jsInference?: true; /** Raw manifest identity: path and bytes. Native archives remain under the * stricter native cache's independent dependency validation. */ ffiProfile: { path: string; bytes: Uint8Array } | null; @@ -362,6 +365,8 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.youngNursery ? ["young-nursery"] : []), ...(options.nodeIslands !== undefined ? [`node-islands:${options.nodeIslands}`] : []), ...(options.heapCensus ? ["heap-census"] : []), + ...(options.shapeOverlay ? ["shape-overlay"] : []), + ...(options.jsInference ? ["js-inference"] : []), options.npmStatic === null ? "" : options.npmStatic === "auto" @@ -413,6 +418,8 @@ function routeKey(options: Omit; /** The classes a class declaration's chain contains, base last. */ classChain(decl: Node): Node[]; + /** Whether every instance of a class has the own field `name` before + * any code can observe the instance. */ + fieldSurelyPresent(decl: Node, name: string): boolean; + /** The atom of a primitive type (`undefined`, `number`, …). */ + prim(name: string): Atom; /** `if` statements in walked code whose condition is known: the branch * whose condition values never allowed it is absent from `walkedBranches`. */ readonly branchIfs: ReadonlySet; @@ -625,6 +630,8 @@ class Analyzer { fnOfInstance: this.fnOfInstance, declValues: (decl) => this.declValues(decl), classChain: (decl) => this.classChain(decl), + fieldSurelyPresent: (decl, name) => this.fieldSurelyPresent(decl, name), + prim: (name) => this.prim(name), branchIfs: this.branchIfs, walkedBranches: this.walkedBranches, }; @@ -1079,30 +1086,48 @@ class Analyzer { return out; } - /** Whether every instance of a class has the own field `name`: a class - * of its chain declares it, or a constructor stores it in a statement - * that runs whenever the constructor completes. */ + /** Whether every instance of a class has the own field `name` before any + * code can observe the instance. Construction runs from the base class + * down, each class's field initializers and then its constructor body: + * the field counts when a declaration or a `this.name = …` statement of + * the constructor body defines it ahead of anything that reads `this` or + * passes it on (a call with `this`, a method call on it, a closure, a + * field initializer or right-hand side that reads it) and ahead of an + * early `return`. */ fieldSurelyPresent(decl: Node, name: string): boolean { - for (const c of this.classChain(decl)) { - for (const m of list((c as ts.ClassLikeDeclaration).members)) { - if (isStatic(m)) continue; - const mn = (m as { name?: Node }).name; - if (ts.isPropertyDeclaration(m) && mn !== undefined && propertyNameText(mn) === name) - return true; - if (!ts.isConstructorDeclaration(m) || m.body === undefined) continue; - for (const st of list(m.body.statements)) { - if (ts.isVariableStatement(st)) continue; - if (!ts.isExpressionStatement(st)) break; - const e = st.expression; - if ( - ts.isBinaryExpression(e) && - e.operatorToken.kind === ts.SyntaxKind.EqualsToken && - ts.isPropertyAccessExpression(e.left) && - e.left.expression.kind === ts.SyntaxKind.ThisKeyword && - e.left.name.text === name - ) - return true; + for (const c of this.classChain(decl).reverse()) { + const members = list((c as ts.ClassLikeDeclaration).members); + for (const m of members) { + if (isStatic(m) || !ts.isPropertyDeclaration(m)) continue; + if (m.initializer !== undefined && observesThis(m.initializer)) return false; + if (propertyNameText(m.name) === name) return true; + } + const ctor = members.find( + (m): m is ts.ConstructorDeclaration => + ts.isConstructorDeclaration(m) && m.body !== undefined, + ); + for (const st of ctor === undefined ? [] : list(ctor.body!.statements)) { + const e = ts.isExpressionStatement(st) ? st.expression : undefined; + if ( + e !== undefined && + ts.isBinaryExpression(e) && + e.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(e.left) && + e.left.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + if (observesThis(e.right)) return false; + if (e.left.name.text === name) return true; + continue; } + // `super(…)` runs the base constructor, walked above. + if ( + e !== undefined && + ts.isCallExpression(e) && + e.expression.kind === ts.SyntaxKind.SuperKeyword && + !list(e.arguments).some(observesThis) + ) + continue; + if (observesThis(st) || returnsEarly(st)) return false; } } return false; @@ -4226,3 +4251,18 @@ function mayBeFalsy(a: Atom): boolean { if (a.kind !== "prim") return false; return a.literal !== true; } + +/** Whether code reads `this` or `super`, at any depth. */ +function observesThis(node: Node): boolean { + if (node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.SuperKeyword) + return true; + return ts.forEachChild(node, (child) => (observesThis(child) ? true : undefined)) === true; +} + +/** Whether a constructor statement can end the constructor: a `return` + * outside the functions it declares. */ +function returnsEarly(node: Node): boolean { + if (ts.isReturnStatement(node)) return true; + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return false; + return ts.forEachChild(node, (child) => (returnsEarly(child) ? true : undefined)) === true; +} diff --git a/packages/compiler/src/frontend/shape-inference/memo.ts b/packages/compiler/src/frontend/shape-inference/memo.ts index 8f5632a42..c917e1f5f 100644 --- a/packages/compiler/src/frontend/shape-inference/memo.ts +++ b/packages/compiler/src/frontend/shape-inference/memo.ts @@ -60,11 +60,16 @@ export function textHash(text: string): string { return createHash("sha256").update(text).digest("hex").slice(0, 32); } -/** The memo key of a program's JavaScript files (name and text). */ +/** The memo key of a program's JavaScript files (name and text) and the + * shape and inference settings. */ export function overlayMemoKey(files: readonly (readonly [string, string])[]): string { const hash = createHash("sha256"); const settings = Object.keys(process.env) - .filter((k) => k.startsWith("SCRIPTC_SHAPE_") && !k.startsWith("SCRIPTC_SHAPE_CHECK")) + .filter( + (k) => + (k.startsWith("SCRIPTC_SHAPE_") && !k.startsWith("SCRIPTC_SHAPE_CHECK")) || + k === "SCRIPTC_INFER_JS", + ) .sort(); for (const k of settings) hash diff --git a/packages/compiler/src/frontend/shape-inference/overlay.ts b/packages/compiler/src/frontend/shape-inference/overlay.ts index db787865e..9014a737e 100644 --- a/packages/compiler/src/frontend/shape-inference/overlay.ts +++ b/packages/compiler/src/frontend/shape-inference/overlay.ts @@ -854,6 +854,10 @@ export function planShapeOverlay( const id = analysis.fields.get(a)?.get(name); if (id !== undefined) for (const x of analysis.nodes[id]!.atoms) atoms.add(x); } + // In the program's own files, code that sees an instance before the + // constructor stores the field reads undefined from it. + if (atoms.size > 0 && !isPackageJsFile(sf) && !analysis.fieldSurelyPresent(cls, name)) + atoms.add(analysis.prim("undefined")); return typeOf(atoms, false, 0); }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index b39531eeb..015f39f64 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; import { setShapeOverlayMemoDir, shapeOverlayEnabled } from "./frontend/shape-inference/index.js"; +import { jsInferenceEnabled } from "./frontend/js-inference.js"; import { nodeFrontendServices } from "./frontend/services-node.js"; import { runTypePatchCommand, @@ -1882,6 +1883,9 @@ async function compileTracked( ...(heapCensusRequested() ? { heapCensus: true as const } : {}), npmStatic: opts.npmStatic ?? null, ...(shapeOverlayEnabled() ? { shapeOverlay: true as const } : {}), + ...(jsInferenceEnabled() && process.env["SCRIPTC_SHAPE_OVERLAY"] !== "0" + ? { jsInference: true as const } + : {}), ffiProfile: opts.ffiProfilePath === undefined || ffiProfileBytes === null ? null diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json new file mode 100644 index 000000000..88052ebb2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/inferred-field-before-assignment.js" + ], + "diags": [] +} diff --git a/tests/corpus/inferred-field-before-assignment.js b/tests/corpus/inferred-field-before-assignment.js new file mode 100644 index 000000000..93f4498a0 --- /dev/null +++ b/tests/corpus/inferred-field-before-assignment.js @@ -0,0 +1,42 @@ +// Code that sees an instance before its constructor stores a field (a +// helper called with `this`, a local holding `this`, a base constructor +// calling an overridden method) reads undefined from the field. +const seen = []; +function inspect(point) { + seen.push(point.x + 1); +} +class Point { + constructor(x) { + inspect(this); + this.x = x; + } +} +class Later { + constructor(x) { + const self = this; + report(self); + this.x = x; + } +} +function report(value) { + seen.push(value.x === undefined ? "absent" : value.x * 2); +} +class Base { + constructor() { + this.describe(); + } + describe() {} +} +class Derived extends Base { + constructor(x) { + super(); + this.x = x; + } + describe() { + seen.push(typeof this.x); + } +} +const a = new Point(1); +const b = new Later(2); +const c = new Derived(3); +console.log(seen.join(","), a.x + b.x + c.x); diff --git a/tests/harness/corpus-costs/inferred-field-before-assignment.js.json b/tests/harness/corpus-costs/inferred-field-before-assignment.js.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/inferred-field-before-assignment.js.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } diff --git a/tests/harness/js-inference.test.ts b/tests/harness/js-inference.test.ts index 8cd1e4d8b..a3ae8fb5e 100644 --- a/tests/harness/js-inference.test.ts +++ b/tests/harness/js-inference.test.ts @@ -43,6 +43,8 @@ const programs = [ "4090-computed-key-tdz.js", // Fields an instance may lack. "8410-dynamic-class-instance-reads.js", + // Fields read before the constructor stores them. + "inferred-field-before-assignment.js", ]; // Programs where the overlay types nothing: the proxy receiver makes the From ee5e659648421d42b22d9663e940b1eb6ac3c85f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:26:34 -0500 Subject: [PATCH 171/193] Regenerate the IR codec for IrFunction.jsEntry The binary IR coders now write and read a closure body's jsval entry, so a function read back from the binary encoding (reused lowering units, lowering journal records, binary IR files) keeps it. The codec round-trip test covers a program with jsval entries and a function with jsEntry set. --- packages/compiler/src/ir/codec.generated.ts | 26 +++++++++++++-------- packages/compiler/src/ir/codec.test.ts | 19 ++++++++++++++- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index 064757b5e..557f85592 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "6493ba8347f973e2"; +export const IR_CODEC_LAYOUT = "a1813b4b2a32bfd0"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -25,7 +25,7 @@ export function decodeIrFunction(r: IrReader): IrFunction { } const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"]; -const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; +const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","jsEntry","tier","speculativeDispatch","fastRead","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"]; const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"]; const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"]; @@ -424,6 +424,11 @@ function encIrFunction(w: IrWriter, v: IrFunction): void { } encT19(w, v.body); encSrcLoc(w, v.loc); + if (v.jsEntry === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.jsEntry); + } if (v.tier === undefined) w.byte(0); else { w.byte(1); @@ -471,14 +476,15 @@ function decIrFunction(r: IrReader): IrFunction { const f7 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r); const f8 = decT19(r); const f9 = decSrcLoc(r); - const f10 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); - const f11 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); - const f12 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); - const f13 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); - const f14 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); - const f15 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); - const f16 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); - const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { async: f13 } : {}), ...(f14 !== undefined ? { generator: f14 } : {}), ...(f15 !== undefined ? { asyncCacheGlobal: f15 } : {}), ...(f16 !== undefined ? { asyncCycleCacheGlobal: f16 } : {}) }; + const f10 = r.tag(2, "IrFunction.jsEntry") === 0 ? undefined : r.str(); + const f11 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); + const f12 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); + const f13 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); + const f14 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); + const f15 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); + const f16 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); + const f17 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); + const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { jsEntry: f10 } : {}), ...(f11 !== undefined ? { tier: f11 } : {}), ...(f12 !== undefined ? { speculativeDispatch: f12 } : {}), ...(f13 !== undefined ? { fastRead: f13 } : {}), ...(f14 !== undefined ? { async: f14 } : {}), ...(f15 !== undefined ? { generator: f15 } : {}), ...(f16 !== undefined ? { asyncCacheGlobal: f16 } : {}), ...(f17 !== undefined ? { asyncCycleCacheGlobal: f17 } : {}) }; return o; } diff --git a/packages/compiler/src/ir/codec.test.ts b/packages/compiler/src/ir/codec.test.ts index ce443af52..0ce314b25 100644 --- a/packages/compiler/src/ir/codec.test.ts +++ b/packages/compiler/src/ir/codec.test.ts @@ -17,7 +17,8 @@ import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js"; import { deserializeModuleBytes } from "./serialize.js"; // Programs with many node kinds: classes, closures, async, generators, -// dynamic values, streams, records, regexes, lone surrogates and BigIntStats. +// dynamic values, closures with jsval entries, streams, records, regexes, +// lone surrogates and BigIntStats. const PROGRAMS = [ "1003-json-parse-unions.ts", "114-live-record-delete.ts", @@ -29,6 +30,7 @@ const PROGRAMS = [ "7018-bigint-stats.ts", "7110-als-completion-callbacks.ts", "7321-speculative-loop-bail-sites.ts", + "8601-dynamic-function-value-numbers.js", ]; /** Whether `a` and `b` hold the same data: the same keys with defined @@ -137,6 +139,21 @@ test("absent optional fields stay absent", () => { expect(Object.keys(back.functions[0]!).sort()).toEqual(Object.keys(source.functions[0]!).sort()); }); +test("a closure body keeps the name of its jsval entry", () => { + const source: IrFunction = { + name: "%fn.add", + params: [], + returnType: VOID, + locals: [], + body: [{ kind: "return", value: null, loc }], + loc, + jsEntry: "%js.%fn.add", + }; + expect(decodeFunctionBinary(encodeFunctionBinary(source)).jsEntry).toBe("%js.%fn.add"); + const back = decodeFunctionBinary(encodeFunctionInOrder(source)!); + expect(JSON.stringify(back)).toBe(JSON.stringify(source)); +}); + test("a function whose objects follow the declared field order reads back with its key order", () => { // As lowering builds functions: the source name after the name, and the // async flag set after the literal. From 4b8894a8bf55d8ece86559910ae3df6c5fe50a23 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:36:01 -0500 Subject: [PATCH 172/193] Check strip-mining in the int32 parameter version of the bounded-loop test `open` in the bounded-loop poll test is versioned on its loop bounds, so its strip-mined loops are in its int32 parameter version. The test now checks them there, and checks that the body the version leaves polls its loops without strip-mining. Also records the CI cost of the loop-bound-parameter-plain-loops corpus program. --- packages/compiler/test/counted-loop-emission.test.ts | 11 +++++++++-- .../loop-bound-parameter-plain-loops.ts.json | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index 30a139a81..cec0a205b 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -280,9 +280,10 @@ test("worker programs poll no loop whose proven ranges bound its runs inside ano expect(rows).not.toMatch(/^loop\.poll\d+:$/m); expect(polls(rows)).toBeGreaterThan(0); // With a limit known only at run time the inner loops keep their polls. - // The innermost one is strip-mined, and a block that ends at the limit + // `open`'s int32 parameter version takes calls with int32 arguments. Its + // innermost loop is strip-mined, and a block that ends at the limit // leaves it without polling: only a block that ends short of it polls. - const open = body("open"); + const open = body("open_int32Params"); expect(polls(open)).toBeGreaterThan(polls(rows)); expect(open).toMatch(/^loop\.poll\d+:$/m); const ends = [...open.matchAll(/^loop\.end\d+:$[\s\S]*?^\S/gm)].map((match) => match[0]); @@ -291,6 +292,12 @@ test("worker programs poll no loop whose proven ranges bound its runs inside ano // A run of up to 16384 iterations is one block, polled as it leaves when // it took more than 64. expect(open).toMatch(/^loop\.poll\.run\d+:$/m); + // The body the version leaves runs for other arguments and emits each + // loop once: its loops poll without strip-mining. + const generic = body("open"); + expect(polls(generic)).toBeGreaterThan(polls(rows)); + expect(generic).not.toMatch(/^loop\.end\d+:$/m); + expect(generic).not.toMatch(/^loop\.poll\.run\d+:$/m); // Steps of 1 below 2^53 are exact, so 50 runs near it are bounded. From // 2^53, `j + 1 === j`: a counter ranging up to it can run without end // and keeps its poll. diff --git a/tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json b/tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json new file mode 100644 index 000000000..864b83e6f --- /dev/null +++ b/tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json @@ -0,0 +1 @@ +{ "plain": 4906, "san": 58104 } From f30f622a9466057710642a8ca60cd25f2b69ecbe Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:47:16 -0500 Subject: [PATCH 173/193] Keep x86-64-v3 copies' code in instrumented and colder-profiled builds - In a module that counts executions for a profile (--profile-generate, also the context-sensitive second phase), every x86-64-v3 copy keeps its code. A copy that took its baseline copy's body would increment the baseline copy's counters, and a --profile-use build would then treat the x86-64-v3 copy as never run. - With a profile, a copy keeps its code when its baseline copy ran fewer times. A profile collected on an x86-64-v3 machine gives the baseline copies no runs, and their bodies are optimized as cold code. --- native/llvm-codegen/src/cpu-copies.cpp | 33 ++++++++- native/llvm-codegen/src/cpu-copies.h | 4 +- .../compiler/test/native-partitions.test.ts | 70 +++++++++++++++---- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/native/llvm-codegen/src/cpu-copies.cpp b/native/llvm-codegen/src/cpu-copies.cpp index d1184aa10..5bbf41ff8 100644 --- a/native/llvm-codegen/src/cpu-copies.cpp +++ b/native/llvm-codegen/src/cpu-copies.cpp @@ -8,6 +8,7 @@ #include "llvm/IR/DerivedTypes.h" #include "llvm/IR/Dominators.h" #include "llvm/IR/Function.h" +#include "llvm/IR/GlobalVariable.h" #include "llvm/IR/InstrTypes.h" #include "llvm/IR/Instructions.h" #include "llvm/IR/IntrinsicInst.h" @@ -17,6 +18,7 @@ #include #include +#include #include #include #include @@ -116,6 +118,30 @@ void useBaselineBody(Function &Copy, Function &Base, } } +// Whether the module counts executions for a profile (`--profile-generate`, +// also after inlining in a context-sensitive second phase): its functions +// increment counters named after themselves (`__profc_`). +bool countsExecutions(const Module &M) { + for (const GlobalVariable &Global : M.globals()) + if (Global.getName().starts_with("__profc_")) + return true; + for (const Function &F : M) + if (F.isIntrinsic() && F.getName().starts_with("llvm.instrprof.")) + return true; + return false; +} + +// Whether Base's body was optimized for fewer runs than Copy's: a profile +// collected where the executable ran its x86-64-v3 copies gives their +// baseline copies no runs, and the optimizer treats those as cold code. +bool ranLess(const Function &Base, const Function &Copy) { + std::optional Runs = Copy.getEntryCount(); + if (!Runs) + return false; + std::optional BaseRuns = Base.getEntryCount(); + return !BaseRuns || BaseRuns->getCount() < Runs->getCount(); +} + } // namespace void useBaselineBodies(Module &M) { @@ -127,7 +153,10 @@ void useBaselineBodies(Module &M) { Functions[stem(F.getName())] = &F; HasCopies |= F.hasFnAttribute(CpuCopyAttribute); } - if (!HasCopies) + // A copy that took its baseline copy's body would count its executions + // against the baseline copy, and a build with the profile would then + // optimize the x86-64-v3 copy as if it never ran. + if (!HasCopies || countsExecutions(M)) return; std::vector> Replaced; for (Function &F : M) { @@ -142,7 +171,7 @@ void useBaselineBodies(Module &M) { Function *Base = It->second; if (Base->isDeclaration() || Base->hasAvailableExternallyLinkage() || Base->getFunctionType() != F.getFunctionType() || - !onlyNarrowVectors(F)) + ranLess(*Base, F) || !onlyNarrowVectors(F)) continue; Replaced.emplace_back(&F, Base); } diff --git a/native/llvm-codegen/src/cpu-copies.h b/native/llvm-codegen/src/cpu-copies.h index 29c155da6..c9323c976 100644 --- a/native/llvm-codegen/src/cpu-copies.h +++ b/native/llvm-codegen/src/cpu-copies.h @@ -21,7 +21,9 @@ inline constexpr const char *CpuCopyAttribute = "scriptc-cpu-copy"; // the level the executable chose. // // A copy is left as it is when its baseline copy is not defined in the -// module or the two copies' types differ. +// module, the two copies' types differ, or the profile gives the baseline +// copy fewer runs (its body is then optimized as colder code). Every copy is +// left as it is in a module that counts executions for a profile. void useBaselineBodies(llvm::Module &M); } // namespace scriptc diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index 344e1babe..947004d33 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -93,6 +93,7 @@ async function emit( optLevel: "0" | "2"; cache?: string; split?: boolean; + profileGenerate?: string; name: string; }, ): Promise { @@ -120,6 +121,9 @@ async function emit( "/src/program.ts", ...(options.cache === undefined ? [] : ["--cache-dir", options.cache]), ...(options.split === true ? ["--split", "codegen"] : []), + ...(options.profileGenerate === undefined + ? [] + : ["--profile-generate", options.profileGenerate]), ]); return outputs; } @@ -654,22 +658,26 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { "attributes #0 = { noinline uwtable }", 'attributes #1 = { noinline uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" }', ].join("\n"); - const objects = await emit(dir, source + "\n", { outputs: 8, optLevel: "2", name: "copies" }); // Each function's object and disassembly, by its name without the // thin link's `.llvm.` suffix. - const functions = new Map(); - for (const [index, object] of objects.entries()) { - const { stdout } = await execFileAsync("objdump", ["-dr", "--no-show-raw-insn", object]); - for (const part of stdout.split(/\n(?=[0-9a-f]+ <)/)) { - const name = /^[0-9a-f]+ <([^>]+)>:/.exec(part)?.[1]?.replace(/\.llvm\.\d+$/, ""); - if (name !== undefined) functions.set(name, { object: index, code: part }); + const disassemble = async (objects: string[]) => { + const functions = new Map(); + for (const [index, object] of objects.entries()) { + const { stdout } = await execFileAsync("objdump", ["-dr", "--no-show-raw-insn", object]); + for (const part of stdout.split(/\n(?=[0-9a-f]+ <)/)) { + const name = /^[0-9a-f]+ <([^>]+)>:/.exec(part)?.[1]?.replace(/\.llvm\.\d+$/, ""); + if (name !== undefined) functions.set(name, { object: index, code: part }); + } } - } - const at = (name: string) => { - const found = functions.get(name); - expect(found, name).toBeDefined(); - return found!; + return (name: string) => { + const found = functions.get(name); + expect(found, name).toBeDefined(); + return found!; + }; }; + const at = await disassemble( + await emit(dir, source + "\n", { outputs: 8, optLevel: "2", name: "copies" }), + ); for (const stem of [ "h", "g", @@ -690,6 +698,44 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { expect(at("g.v3").code).toContain("%ymm"); expect(at("s.v3").code).toMatch(/\svaddsd\s/); expect(at("g.base").code).not.toContain("%ymm"); + // An instrumented build keeps every copy's code: a baseline body would + // count the v3 copy's executions against the baseline copy. + const counted = await disassemble( + await emit(dir, source + "\n", { + outputs: 8, + optLevel: "2", + profileGenerate: join(dir, "copies-%p.profraw"), + name: "counted", + }), + ); + for (let index = 0; index < pairs; index++) + expect(counted(`f${index}.v3`).code).toMatch(/\svaddpd\s/); + expect(counted("h.v3").code).toMatch(/\svmulpd\s/); + // A profile that gives f0's baseline copy fewer runs than its v3 copy + // (collected on an x86-64-v3 machine) keeps f0's v3 code: the baseline + // body is optimized as colder code. f1 has no profile counts. + const profiled = source + .replace( + "define internal void @f0.v3(ptr %p) #1 {", + "define internal void @f0.v3(ptr %p) #1 !prof !90 {", + ) + .replace( + "define internal void @f0.base(ptr %p) #0 {", + "define internal void @f0.base(ptr %p) #0 !prof !91 {", + ); + const runs = await disassemble( + await emit( + dir, + [ + profiled, + '!90 = !{!"function_entry_count", i64 1000}', + '!91 = !{!"function_entry_count", i64 0}', + ].join("\n") + "\n", + { outputs: 8, optLevel: "2", name: "runs" }, + ), + ); + expect(runs("f0.v3").code).toMatch(/\svaddpd\s/); + expect(runs("f1.v3").code).not.toMatch(/\svaddpd\s/); }, ); From f521dc91b1a4c05db657dcc59b89150c803bdb0c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:51:09 -0500 Subject: [PATCH 174/193] Compile three Node test shapes: a top-level return before declarations, nullable strictEqual operands, and a branch-assigned let A top-level return in a CommonJS module compiles when declarations follow it. When the return is taken, only code that already exists can observe the bindings it skips: functions and classes created before the return, and function declarations after it. A module where one of them reads a binding declared after the return, or that assigns exports after it, still stops with SC1090. assert.strictEqual and assert.notStrictEqual compare a nullable number, string or boolean (`number | null`, `string | null`) with a scalar, null or undefined through the checked-dynamic SameValue compare, which reports Node's failure messages. notStrictEqual of another nullable value, such as `child.stdin`, against null or undefined tests the value's tag. An initializer-less JavaScript `let` whose writes all agree also takes their type when the writes are calls declared in the standard library, such as `spawn(...)`, and at file scope. A runtime handle that a closure captures takes the handle or undefined. Inside a nested function, a member read on it (`c.address()`, `child.stdin`) reads the handle and throws Node's TypeError while the binding is still undefined; other reads there take the checked-dynamic value. --- .../limitations/builtin-apis/assertions.mdx | 2 + .../language-features/commonjs-require.mdx | 2 + .../lowering/expressions/identifiers.ts | 55 ++++++ .../src/frontend/lowering/lower-assert.ts | 79 ++++++++- .../src/frontend/lowering/lower-modules.ts | 11 +- .../src/frontend/lowering/lower-stmts.ts | 165 ++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 4 + .../assert-strict-equal-nullable.ts.json | 6 + ...s-top-level-return-later-bindings.cjs.json | 6 + .../evolving-let-child-process.cjs.json | 6 + tests/corpus/assert-strict-equal-nullable.ts | 72 ++++++++ .../cjs-top-level-return-later-bindings.cjs | 28 +++ tests/corpus/evolving-let-child-process.cjs | 46 +++++ tests/diagnostics/assert-fences.ts | 8 +- .../__snapshots__/assert-fences.ts.txt | 4 +- .../assert-strict-equal-nullable.ts.json | 1 + ...s-top-level-return-later-bindings.cjs.json | 1 + .../evolving-let-child-process.cjs.json | 1 + 18 files changed, 456 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json create mode 100644 tests/corpus/assert-strict-equal-nullable.ts create mode 100644 tests/corpus/cjs-top-level-return-later-bindings.cjs create mode 100644 tests/corpus/evolving-let-child-process.cjs create mode 100644 tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json create mode 100644 tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json create mode 100644 tests/harness/corpus-costs/evolving-let-child-process.cjs.json diff --git a/docs/content/limitations/builtin-apis/assertions.mdx b/docs/content/limitations/builtin-apis/assertions.mdx index 381fc3802..84c830e79 100644 --- a/docs/content/limitations/builtin-apis/assertions.mdx +++ b/docs/content/limitations/builtin-apis/assertions.mdx @@ -5,6 +5,8 @@ `assert.AssertionError` used as a value outside `instanceof` throws `SC2020`. The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument. `throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`. + `strictEqual` and `notStrictEqual` compare a number, string or boolean that can be `null` or `undefined`, such as `child.signalCode`, with any number, string, boolean, `null` or `undefined`. + `notStrictEqual` of another value that can be `null` or `undefined`, such as `child.stdin`, against `null` or `undefined` compiles; `strictEqual` of one throws `SC2020`. `assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused. `assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals. Checked-dynamic values compared deeply have no cycle detection. diff --git a/docs/content/limitations/language-features/commonjs-require.mdx b/docs/content/limitations/language-features/commonjs-require.mdx index f0bf67c4e..f13ca143f 100644 --- a/docs/content/limitations/language-features/commonjs-require.mdx +++ b/docs/content/limitations/language-features/commonjs-require.mdx @@ -7,3 +7,5 @@ Defaults, rest elements, and nested patterns over other modules are unsupported. A require cycle compiles when each of its modules assigns all of its exports before its first `require` and uses the other modules of the cycle only inside functions that its top-level code can't call. Other require cycles are rejected with `SC1016`. + A top-level `return` ends the module's evaluation. + It is unsupported when the module assigns exports after it, or when a function that exists before it runs reads a binding declared after it, such as a callback created above the `return` or a function declaration. diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index a790f57a6..eb3f7e7aa 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -192,6 +192,11 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // when the checker types this USE as a single arm, maybeNarrow // bridges the tagged representation with a unionNarrow. const local = lowerer.resolveLocal(expr); + { + const slot = local ?? lowerer.globalOf(expr); + const closureRead = slot ? closureDynRead(lowerer, expr, slot.id, slot.type, loc) : null; + if (closureRead) return closureRead; + } // A Date local holding its time value reads as that Date: only uses // that read the time value reach here (date-values.ts). if (local && lowerer.timeValueDateLocals.has(local)) { @@ -1218,3 +1223,53 @@ export function uriFunctionValue(lowerer: Lowerer, which: string, loc: SrcLoc): } return { kind: "closure", fnName: name, captures: [], type, loc }; } + +/** A read of a captured handle `let` (Lowerer.closureDynLets) inside a + * function nested in the binding's scope. The checker types it as the + * handle, but the closure can run before the first write. As a member + * receiver (`c.address()`, `child.stdin`) it reads the handle and throws + * Node's TypeError when the binding is still undefined; elsewhere it reads + * the checked-dynamic value of the handle or undefined. Null for other + * reads. */ +function closureDynRead( + lowerer: Lowerer, + expr: ts.Identifier, + id: string, + type: IrType, + loc: SrcLoc, +): IrExpr | null { + if (type.kind !== "union" || lowerer.closureDynLets.size === 0) return null; + const symbol = lowerer.resolveValueSymbol(expr); + if (!symbol || !lowerer.closureDynLets.has(symbol)) return null; + const decl = symbol.valueDeclaration; + if (!decl) return null; + const scopeOf = (node: ts.Node): ts.Node | undefined => { + let at = node.parent; + while (at !== undefined && !ts.isFunctionLike(at) && !ts.isSourceFile(at)) at = at.parent; + return at; + }; + if (scopeOf(expr) === scopeOf(decl)) return null; + const parent = expr.parent; + const handle = lowerer.unions.get(type.unionId)?.arms.find((arm) => !isUnitType(arm)); + if (handle && ts.isPropertyAccessExpression(parent) && parent.expression === expr) { + const helper = lowerer.narrowedArmHelper( + type.unionId, + handle, + loc, + `Cannot read properties of undefined (reading '${parent.name.text}')`, + ); + if (helper) + return lowerer.checkedNarrowCall( + helper, + { kind: "varRef", localId: id, type, loc }, + handle, + loc, + ); + } + return { + kind: "dynFrom", + value: { kind: "varRef", localId: id, type, loc }, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index 704314eaf..d167bb036 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1290,12 +1290,28 @@ function lowerAssertEqual( ?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ?? false))) && lowerer.dynConvertible(e.type); + // A nullable number, string or boolean (`number | null`) against a + // scalar or null/undefined: under the strict pair each arm crosses by + // value, so the checked-dynamic SameValue compare answers, and renders + // its failure message, as Node does. + const strictArm = (t: IrType): boolean => + t.kind === "f64" || t.kind === "string" || t.kind === "bool" || isUnitType(t); + const strictOperand = (e: IrExpr): boolean => + !deep && + (e.kind === "unitLit" || + strictArm(e.type) || + (e.type.kind === "union" && + (lowerer.unions.get(e.type.unionId)?.arms.every(strictArm) ?? false))) && + lowerer.dynConvertible(e.type); + const nullable = (e: IrExpr): boolean => e.type.kind === "union" || isUnitType(e.type); + const strictNullable = (nullable(a) || nullable(b)) && strictOperand(a) && strictOperand(b); if ( a.type.kind === "dyn" || b.type.kind === "dyn" || a.type.kind === "moduleNs" || b.type.kind === "moduleNs" || - (instance(a) && instance(b)) + (instance(a) && instance(b)) || + strictNullable ) { const box = (e: IrExpr, node: ts.Expression): IrExpr => { if (e.type.kind === "dyn") return e; @@ -1339,6 +1355,7 @@ function lowerAssertEqual( // re-encoding and losing identity (SEMANTICS.md). (k === "object" && e.type.kind === "object" && e.type.className === "%Error") || instance(e) || + strictOperand(e) || // A symbol crosses as itself, so strict compares are identity. (!deep && k === "symbol") || (deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) || @@ -1472,6 +1489,66 @@ function lowerAssertEqual( loc, }; } + // notStrictEqual of a nullable value whose other arms do not convert + // (`child.stdin`, a stream or null) against null or undefined fails only + // when the value is that unit, and the failure message renders only the + // unit. The value's tag test stands in for it: the unit when it matches, + // `false` otherwise, which the unit never equals. A union without that + // unit arm never equals it. + if (!deep && negated) { + const unitSide = isUnitType(a.type) ? a : isUnitType(b.type) ? b : null; + const other = unitSide === a ? b : a; + if (unitSide !== null && other.type.kind === "union") { + const tag = lowerer.armTag(other.type.unionId, unitSide.type); + const unit = unitSide.type.kind === "nullT" ? "null" : "undefined"; + const unequal: IrExpr = { kind: "dynFrom", value: boolLit(false, loc), type: DYN, loc }; + const tested: IrExpr = + tag < 0 + ? { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: other, loc }], + result: unequal, + type: DYN, + loc, + } + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: other.type.unionId, + tag, + negated: false, + value: other, + type: BOOL, + loc, + }, + then: { + kind: "dynFrom", + value: { kind: "unitLit", unit, type: unitSide.type, loc }, + type: DYN, + loc, + }, + else_: unequal, + type: DYN, + loc, + }; + const unitDyn: IrExpr = { kind: "dynFrom", value: unitSide, type: DYN, loc: unitSide.loc }; + return { + kind: "libCall", + fn: "assert.eqDyn", + args: [ + unitSide === a ? unitDyn : tested, + unitSide === a ? tested : unitDyn, + boolLit(true, loc), + boolLit(false, loc), + msg, + hasMsg, + ], + type: VOID, + loc, + }; + } + } if (!deep) { const hint = a.type.kind === "union" || b.type.kind === "union" diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce58..6ebad7275 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -131,6 +131,7 @@ import { funcTypeFromParamShapes, registerOverloadedCallableAlias } from "./call import { jsBindingHasOpenWrites, hasJsTypeAnnotation, + inferredEvolvingLetType, isVarDeclared, numericIteratorSourceOf, provenanceElidedConstDecl, @@ -2438,10 +2439,16 @@ export function collectGlobals( ) { const symbol = lowerer.checker.getSymbolAtLocation(nameNode); if (symbol && !lowerer.globalsBySymbol.has(symbol)) { + // An initializer-less `let` whose writes all agree keeps + // their type, as a function's local does (lowerVarDecl). + const evolved = + isLet && !isVarDeclared(decl) && decl.initializer === undefined + ? inferredEvolvingLetType(lowerer, decl) + : null; const g: IrGlobal = { id: `%g.${tag}${nsPrefix}${nameNode.text}`, name: nameNode.text, - type: DYN, + type: evolved ?? DYN, mutable: isLet, source: bindingSource(nameNode), }; @@ -2450,7 +2457,7 @@ export function collectGlobals( // Mutable dyn globals hold the dyn undefined from module // entry (the main path's rule below): a closure called // above the declaration reads undefined instead of - // faulting on NULL. + // faulting on NULL. An undefined-armed union does the same. if (g.mutable) noteVarGlobalEntryInit(lowerer, sf, g); } continue; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 8231365b1..3332807a4 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -98,6 +98,7 @@ import { BYTES_U8, CAUGHT, DATE_T, + DYN_HANDLE_KINDS, DYN, F64, type IrExpr, @@ -1765,10 +1766,12 @@ const THROWN_AS_DYN: ReadonlySet = new Set([ /** A `return` at the top level of a CommonJS module (preflight admits it * there only): it ends the module's evaluation, and its value is - * evaluated and discarded, as Node's module wrapper does. Top-level - * bindings and export assignments after it would be left uninitialized - * where Node leaves them in their temporal dead zone or absent, so a - * module with any keeps a refusal. Null for a return inside a function. */ + * evaluated and discarded, as Node's module wrapper does. Bindings + * declared after it stay uninitialized when it is taken, which only code + * that exists by then can observe: a function created before the return, + * or a hoisted function declaration. A module whose such functions read a + * later binding, or that assigns exports after the return, keeps a + * refusal. Null for a return inside a function. */ function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { let top: ts.Node = stmt; while (top.parent !== undefined && !ts.isSourceFile(top.parent)) { @@ -1778,19 +1781,34 @@ function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrS const sf = top.parent; if (sf === undefined || !ts.isSourceFile(sf)) return null; const stmts = sf.statements.nodes; - const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1); - if ( - later.some( - (s) => - ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null, - ) - ) { + const index = stmts.indexOf(top as ts.Statement); + const later = stmts.slice(index + 1); + if (later.some((s) => cjsExportAssignmentOf(s) !== null)) { lowerer.unsupported( "SC1090", stmt, - "a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it", + "a top-level return in a CommonJS module that assigns exports after it", ); } + const laterBindings = laterModuleBindings(lowerer, later); + if (laterBindings.size > 0) { + // Functions that exist when the return is taken: every function in the + // statements up to the return, and the hoisted declarations after it. + const reachable = [ + ...stmts.slice(0, index + 1), + ...later.filter((s) => ts.isFunctionDeclaration(s)), + ]; + const read = reachable + .map((s) => functionReadOf(lowerer, s, laterBindings, ts.isFunctionDeclaration(s))) + .find((r) => r !== null); + if (read) { + lowerer.unsupported( + "SC1090", + read, + `reading '${read.text}', declared after a top-level CommonJS return, from a function that can run once the return is taken`, + ); + } + } const loc = locOf(stmt); const ret: IrStmt = { kind: "return", value: null, loc }; if (!stmt.expression) return ret; @@ -1801,6 +1819,57 @@ function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrS }; } +/** The module-scope bindings `stmts` declare: variables and classes, with + * `var` declarations in nested blocks. Bindings inside functions are not + * module-scope and are skipped. */ +function laterModuleBindings(lowerer: Lowerer, stmts: readonly ts.Statement[]): Set { + const bindings = new Set(); + const addName = (name: ts.BindingName): void => { + if (ts.isIdentifier(name)) { + const symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol) bindings.add(symbol); + return; + } + for (const element of name.elements.nodes) { + if (ts.isBindingElement(element) && element.name) addName(element.name); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassStaticBlockDeclaration(node)) return; + if (ts.isVariableDeclaration(node)) addName(node.name); + if (ts.isClassDeclaration(node) && node.name) addName(node.name); + ts.forEachChild(node, visit); + }; + for (const stmt of stmts) visit(stmt); + return bindings; +} + +/** The first identifier inside a function or class in `node` (anywhere in + * `node` when `inFunction`) that reads one of `bindings`, or null. */ +function functionReadOf( + lowerer: Lowerer, + node: ts.Node, + bindings: ReadonlySet, + inFunction: boolean, +): ts.Identifier | null { + if (inFunction && ts.isIdentifier(node)) { + const symbol = lowerer.resolveValueSymbol(node); + return symbol && bindings.has(symbol) ? node : null; + } + // Class bodies count as functions: field initializers run per instance. + const nested = + inFunction || + ts.isFunctionLike(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node); + return ( + ts.forEachChild( + node, + (child) => functionReadOf(lowerer, child, bindings, nested) ?? undefined, + ) ?? null + ); +} + export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { if (ownPrototypeResetStatement(lowerer, stmt)) return { kind: "block", body: [], loc: locOf(stmt) }; @@ -5917,15 +5986,24 @@ function evolvingArrayHolderType(lowerer: Lowerer, decl: ts.VariableDeclaration) /** A single static type for an initializer-less JavaScript `let` whose * later plain writes all agree. An evolving JS local reads as `undefined` * until control flow proves a write, so a scalar slot is sound only when - * every ordinary use already has that concrete flow type. Any closure - * capture stays checked-dynamic: it can observe the local before an outer - * assignment, and the checker deliberately leaves captured evolving lets - * broad. */ -function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null { + * every ordinary use already has that concrete flow type. A write's call + * counts when its signature is declared in the standard library, whose + * return types are declared rather than inferred from JavaScript. A + * closure capture can observe the local before an outer assignment, and + * the checker leaves captured evolving lets broad: a captured runtime + * handle (`let child; if (...) child = spawn(...)`) takes the handle or + * `undefined`, which nested functions read as a checked-dynamic value + * (closureDynLets), and other captured bindings stay checked-dynamic. */ +export function inferredEvolvingLetType( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): IrType | null { if (!ts.isIdentifier(decl.name)) return null; const symbol = lowerer.checker.getSymbolAtLocation(decl.name); const scope = decl.parent?.parent?.parent; if (symbol === undefined || (!ts.isBlock(scope) && !ts.isSourceFile(scope))) return null; + // Another module reads an exported binding, which this scan does not see. + if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) return null; // A shorthand property's name (`{ width }`) resolves to the property // symbol, but it reads the binding: the checker's shorthand-value symbol. @@ -5960,9 +6038,19 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) const writes: IrType[] = []; const reads: ts.Identifier[] = []; let invalid = false; - ts.walkPreorder(scope, (node) => { - if (node !== scope && ts.isFunctionLike(node)) { - if (captures(node)) invalid = true; + let captured = false; + // The capturing closure being scanned: reads inside one carry the + // checker's broad type, so only its writes are collected. + let closure: ts.Node | null = null; + const visit = (node: ts.Node): void | "skip" | "stop" => { + if (node !== scope && node !== closure && ts.isFunctionLike(node)) { + if (captures(node)) { + captured = true; + const outer = closure; + closure = node; + ts.walkPreorder(node, visit); + closure = outer; + } return "skip"; } if (ts.isBinaryExpression(node)) { @@ -5973,11 +6061,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) invalid = true; return "stop"; } - if ( - ts.isCallExpression(node.right) && - (implicitMethodCallInfersReturn(lowerer, node.right) || - isJsSourceFile(ts.getSourceFileOfNode(node.right))) - ) { + if (ts.isCallExpression(node.right) && !stdlibDeclaredCall(lowerer, node.right)) { invalid = true; return "stop"; } @@ -6006,6 +6090,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) return "stop"; } if ( + closure === null && ts.isIdentifier(node) && node !== decl.name && refersToBinding(node) && @@ -6014,16 +6099,32 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) reads.push(node); } return undefined; - }); + }; + ts.walkPreorder(scope, visit); const first = writes[0]; if (invalid || first === undefined || !writes.every((write) => typeEquals(write, first))) return null; - return reads.every((read) => { - const mapped = lowerer.mapTypeOf(lowerer.typeOf(read)); - return mapped !== null && typeEquals(mapped, first); - }) - ? first - : null; + if ( + !reads.every((read) => { + const mapped = lowerer.mapTypeOf(lowerer.typeOf(read)); + return mapped !== null && typeEquals(mapped, first); + }) + ) + return null; + if (!captured) return first; + if (!DYN_HANDLE_KINDS.has(first.kind)) return null; + lowerer.closureDynLets.add(symbol); + return { kind: "union", unionId: lowerer.unions.intern([first, UNDEFINED_T]) }; +} + +/** Whether a call's resolved signature is declared in the standard library + * (`spawn(...)`), so its declared return type is the value's type. Calls of + * JavaScript functions, and implicit generic methods, infer theirs, and a + * `require(...)` result is a module rather than a value of its type. */ +function stdlibDeclaredCall(lowerer: Lowerer, call: ts.CallExpression): boolean { + if (implicitMethodCallInfersReturn(lowerer, call) || requireSpecOf(call) !== null) return false; + const declaration = lowerer.checker.getResolvedSignature(call)?.declaration; + return declaration !== undefined && lowerer.isStdlibFile(ts.getSourceFileOfNode(declaration)); } export function lowerVarDecl( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ef9d5c31c..0b73bc0e4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3993,6 +3993,10 @@ export class Lowerer { * function called above the declaration statement reads that, never a * NULL slot. Filled by collectGlobals. */ readonly varGlobalEntryInits = new Map(); + /** Initializer-less JavaScript lets holding a runtime handle or undefined + * that a closure captures (inferredEvolvingLetType). Reads inside a nested + * function lower as the checked-dynamic value. */ + readonly closureDynLets = new Set(); /** Import-cycle bindings with an initialization flag, by global id, and * the top-level statements declaring them (cycle-initialization.ts). */ readonly cycleInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json new file mode 100644 index 000000000..92d5e4440 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/assert-strict-equal-nullable.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json new file mode 100644 index 000000000..938628e19 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/cjs-top-level-return-later-bindings.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json new file mode 100644 index 000000000..9ab5efe31 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/evolving-let-child-process.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/assert-strict-equal-nullable.ts b/tests/corpus/assert-strict-equal-nullable.ts new file mode 100644 index 000000000..0a9051c56 --- /dev/null +++ b/tests/corpus/assert-strict-equal-nullable.ts @@ -0,0 +1,72 @@ +// assert.strictEqual and assert.notStrictEqual compare a nullable number, +// string or boolean with SameValue against a value of another type or +// null/undefined, and notStrictEqual compares a nullable stream against +// null or undefined. Failures report Node's messages. +import assert from "node:assert"; +import { spawn, spawnSync } from "node:child_process"; + +function find(values: number[], value: number): number | null { + const index = values.indexOf(value); + return index < 0 ? null : index; +} +function label(n: number): string | null { + return n > 0 ? "positive" : null; +} +function ratio(n: number): number | undefined { + return n === 0 ? undefined : n / n; +} +function flag(n: number): boolean | null { + return n === 0 ? null : n > 0; +} + +assert.strictEqual(find([1, 2, 3], 2), 1); +assert.strictEqual(find([1, 2, 3], 9), null); +assert.notStrictEqual(find([1, 2, 3], 3), null); +assert.notStrictEqual(find([1, 2, 3], 3), 1); +assert.strictEqual(label(1), "positive"); +assert.strictEqual(label(-1), null); +assert.notStrictEqual(label(-1), undefined); +assert.strictEqual(ratio(0), undefined); +assert.strictEqual(ratio(-0), undefined); +assert.strictEqual(flag(2), true); +assert.strictEqual(flag(0), null); +assert.strictEqual(null, find([], 1)); +console.log("passing comparisons ok"); + +const nan: number | null = Number("x"); +assert.strictEqual(nan, NaN); + +function report(run: () => void): void { + try { + run(); + console.log("no throw"); + } catch (e) { + const error = e as assert.AssertionError; + console.log(error.name, error.code, error.operator, error.generatedMessage); + console.log(JSON.stringify(error.message)); + console.log(String(error.actual), String(error.expected)); + } +} +report(() => assert.strictEqual(find([1], 1), null)); +report(() => assert.strictEqual(find([1], 2), 0)); +report(() => assert.strictEqual(label(1), null)); +report(() => assert.strictEqual(label(-1), "positive", "label mismatch")); +report(() => assert.notStrictEqual(find([1], 2), null)); +report(() => assert.notStrictEqual(label(3), "positive")); +report(() => assert.strictEqual(flag(0), false)); +report(() => assert.strictEqual(find([0], 0), -0)); + +const result = spawnSync("true"); +assert.strictEqual(result.status, 0); +assert.strictEqual(result.signal, null); +const child = spawn("true", [], { stdio: ["ignore", "pipe", "pipe"] }); +assert.notStrictEqual(child.stdout, null); +assert.notStrictEqual(child.stdout, undefined); +assert.notStrictEqual(child.stdin, undefined); +report(() => assert.notStrictEqual(child.stdin, null)); +assert.strictEqual(child.signalCode, null); +child.on("close", (code) => { + assert.strictEqual(child.exitCode, 0); + assert.strictEqual(child.signalCode, null); + console.log("child closed", code); +}); diff --git a/tests/corpus/cjs-top-level-return-later-bindings.cjs b/tests/corpus/cjs-top-level-return-later-bindings.cjs new file mode 100644 index 000000000..3cde1f32b --- /dev/null +++ b/tests/corpus/cjs-top-level-return-later-bindings.cjs @@ -0,0 +1,28 @@ +'use strict'; +// A top-level return in a CommonJS module can precede declarations that +// only the rest of the module reads. The timer and exit handler registered +// before the return run, a callback's own binding shadows a later one of +// the same name, and a function declared after the return is hoisted. +const fs = require('fs'); + +console.log('start', describe()); +setTimeout(() => { + const socket = 'inner socket'; + console.log('timer', socket); +}, 1); +process.on('exit', (code) => console.log('exit', code)); +for (const name of ['a', 'b']) { + if (name === 'b' && !fs.existsSync('/scriptc-missing-path')) return; + console.log('visited', name); +} + +const socket = 'outer socket'; +let count = 2; +var total = count + 1; +class Server {} +const [first, second] = [count, total]; +for (var i = 0; i < 2; i++) console.log('not reached', i); +console.log('not reached', socket, first, second, new Server()); +function describe() { + return 'hoisted'; +} diff --git a/tests/corpus/evolving-let-child-process.cjs b/tests/corpus/evolving-let-child-process.cjs new file mode 100644 index 000000000..2cdc34700 --- /dev/null +++ b/tests/corpus/evolving-let-child-process.cjs @@ -0,0 +1,46 @@ +// An initializer-less `let` assigned a child process on every branch keeps +// the child's type, including when callbacks read it, as Node's +// child_process tests do when they choose a command per platform. +const { spawn } = require('child_process'); + +let child; +if (process.platform === 'win32') child = spawn('cmd.exe', ['/c', 'echo', 'hi']); +else child = spawn('echo', ['hi']); +let text = ''; +child.stdout.on('data', (chunk) => { + text += chunk; +}); + +let producer, consumer; +if (process.platform === 'win32') { + producer = spawn('cmd.exe', ['/c', 'echo', 'hello&&', 'echo', 'node']); + consumer = spawn('findstr', ['o']); +} else { + producer = spawn('printf', ['hello\nnode\nand\nworld\n']); + consumer = spawn('grep', ['o']); +} +producer.stdout.on('data', (data) => { + if (!consumer.stdin.write(data)) producer.stdout.pause(); +}); +consumer.stdin.on('drain', () => producer.stdout.resume()); +producer.stdout.on('end', () => consumer.stdin.end()); +let piped = ''; +consumer.stdout.on('data', (data) => { + piped += data.toString('utf8', 0, data.length); +}); + +let quiet; +if (process.platform === 'win32') quiet = spawn('cmd.exe', ['/c', 'echo', 'quiet']); +else quiet = spawn('echo', ['quiet']); +quiet.stdout.setEncoding('utf8'); +quiet.stdout.resume(); + +let count; +if (process.argv.length > 10) count = Math.max(1, 2); +else count = Math.min(3, 4); +console.log(count.toFixed(1)); + +process.on('exit', () => { + console.log(JSON.stringify(text), JSON.stringify(piped)); + console.log(child.exitCode, producer.exitCode, consumer.exitCode, consumer.signalCode, quiet.exitCode); +}); diff --git a/tests/diagnostics/assert-fences.ts b/tests/diagnostics/assert-fences.ts index 7cf96bc5b..0a8cd9c0e 100644 --- a/tests/diagnostics/assert-fences.ts +++ b/tests/diagnostics/assert-fences.ts @@ -1,7 +1,7 @@ // The assert surface's honest fences: loose deep equality over recursive // types (equal/notEqual, deepEqual/notDeepEqual and assert/strict's aliases lower), -// reference-equality strictEqual on objects, -// union operands (narrow first), mismatched or uncomparable deep types +// reference-equality strictEqual on objects, union operands with object +// arms (narrow first), mismatched or uncomparable deep types // (typed arrays, class instances), namespace-object calls, unsupported // throws/rejects expectations (properties outside the static error // surface, empty shapes, non-literal objects, doesNotReject shapes — @@ -19,9 +19,9 @@ assert.notDeepEqual([1], [2]); const box = { n: 1 }; assert.strictEqual(box, box); // reference equality — deepStrictEqual hint -let maybe: string | undefined = "x"; +let maybe: { n: number } | undefined = box; if ("x".length === 0) maybe = undefined; -assert.strictEqual(maybe, "x"); // union operand — narrow first +assert.strictEqual(maybe, box); // union operand with an object arm — narrow first const unk: unknown = JSON.parse("{}"); assert.strictEqual(unk, { n: 1 }); // strict + composite static side: reference identity does not survive the unknown boundary diff --git a/tests/harness/__snapshots__/assert-fences.ts.txt b/tests/harness/__snapshots__/assert-fences.ts.txt index 041c20e9b..8ee90b796 100644 --- a/tests/harness/__snapshots__/assert-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-fences.ts.txt @@ -7,10 +7,10 @@ assert-fences.ts:20:1 - error SC2020: 'assert.strictEqual of '{ n: number }' and hint: strictEqual on objects is reference equality — deepStrictEqual compares structure -assert-fences.ts:24:1 - error SC2020: 'assert.strictEqual of 'string | undefined' and 'string' values' is part of the standard library types but has no scriptc lowering yet +assert-fences.ts:24:1 - error SC2020: 'assert.strictEqual of '{ n: number } | undefined' and '{ n: number }' values' is part of the standard library types but has no scriptc lowering yet 23 | if ("x".length === 0) maybe = undefined; - 24 | assert.strictEqual(maybe, "x"); // union operand — narrow first + 24 | assert.strictEqual(maybe, box); // union operand with an object arm — narrow first | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | diff --git a/tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json b/tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json new file mode 100644 index 000000000..f316dc50b --- /dev/null +++ b/tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json @@ -0,0 +1 @@ +{ "plain": 5208, "san": 7954 } diff --git a/tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json b/tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json new file mode 100644 index 000000000..5f011f8c6 --- /dev/null +++ b/tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json @@ -0,0 +1 @@ +{ "plain": 3714, "san": 4365 } diff --git a/tests/harness/corpus-costs/evolving-let-child-process.cjs.json b/tests/harness/corpus-costs/evolving-let-child-process.cjs.json new file mode 100644 index 000000000..5196b4f3b --- /dev/null +++ b/tests/harness/corpus-costs/evolving-let-child-process.cjs.json @@ -0,0 +1 @@ +{ "plain": 4340, "san": 4815 } From 677710075ac5bf6a6ee99c8a6d2c97bb2716a6db Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:27:04 -0700 Subject: [PATCH 175/193] Let an empty `??` array default adopt the left's array arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `''.match(re) ?? []` typed the literal uninhabited (`never[]`), whose element still mapped through never's f64 value representation, so the whole expression was `RegExpMatchArray | number[]` — a result no single array slot can receive. A union return such as `"all" | readonly string[]` refused with SC2003 "union conversion has no unambiguous layout mapping" (`getScriptReach` in a coding agent's tool-discovery path). An EMPTY array-literal default has no element of its own: it adopts the left's present array arm, exactly the rule the union slot and the ternary arm already state for an empty literal. The left's nullish test and the lazy default are unchanged, and a default whose own type already matches the left keeps the identical lowering it had. Corpus: tests/corpus/nullish-empty-array-default.ts pins the union return, string element reads through the default, the declared optional-array slot with a fresh empty value per evaluation, and a parenthesized/template default. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/expressions/binary.ts | 30 ++++++++++++++ .../nullish-empty-array-default.ts.json | 6 +++ tests/corpus/nullish-empty-array-default.ts | 40 +++++++++++++++++++ .../nullish-empty-array-default.ts.json | 1 + 4 files changed, 77 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json create mode 100644 tests/corpus/nullish-empty-array-default.ts create mode 100644 tests/harness/corpus-costs/nullish-empty-array-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 48ea9c490..a6d7ad674 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -380,6 +380,19 @@ function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boole }); } +/** An empty `[]` literal whose own type is `never[]` (no contextual array + * type supplied one): the `??` default of an optional array read. */ +function emptyNeverArrayLiteral(lowerer: Lowerer, node: ts.Expression): boolean { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return false; + const own = lowerer.typeOf(value); + return ( + lowerer.checker.isArrayType(own) && + (lowerer.checker.getTypeArguments(own as ts.TypeReference)[0]!.flags & ts.TypeFlags.Never) !== 0 + ); +} + export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -431,6 +444,23 @@ export function lowerNullishPair( const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); return { kind: "nullish", left, right, type: rest[0]!, loc }; } + // An EMPTY array-literal default (`''.match(re) ?? []`) has no element of + // its own: tsc infers `never[]`, whose f64-element mapping the whole + // expression keeps as a second array arm (`RegExpMatchArray | number[]`), + // a result no single array slot can receive. It adopts the left's present + // array arm instead — the empty-literal rule the union slot and the + // ternary arm already state — so `?? []` answers the left's array type. + // A JavaScript `[]` is `any[]` and keeps the dynamic path. + if ( + rest.length === 1 && + rest[0]!.kind === "array" && + !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) && + emptyNeverArrayLiteral(lowerer, expr.right) + ) { + const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); + if (typeEquals(right.type, rest[0]!)) + return { kind: "nullish", left, right, type: rest[0]!, loc }; + } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations // call the left side present. Preserve a differently typed primitive diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json new file mode 100644 index 000000000..c3bb5cdf7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/nullish-empty-array-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/nullish-empty-array-default.ts b/tests/corpus/nullish-empty-array-default.ts new file mode 100644 index 000000000..49595a1f1 --- /dev/null +++ b/tests/corpus/nullish-empty-array-default.ts @@ -0,0 +1,40 @@ +// `''.match(re) ?? []`: the empty array-literal default of a `??` adopts the +// left's present array arm. tsc gives the literal the uninhabited `never[]` +// element, which the compiler maps through never's f64 value representation +// (`number[]`), so the whole expression used to be `RegExpMatchArray | +// number[]` — a result no single array slot can receive. + +// A union return (`"all" | readonly string[]`) whose array arm is the +// `?? []` result. +function reach(code: unknown): "all" | readonly string[] { + if (typeof code !== "string") return []; + return /\bALL\b/.test(code) ? "all" : (code.match(/\w+/g) ?? []); +} +console.log(JSON.stringify(reach(7))); +console.log(JSON.stringify(reach("ALL"))); +console.log(JSON.stringify(reach("a.b c"))); +console.log(JSON.stringify(reach(""))); + +// The default's element type is the left's: string element reads and a +// map callback over the value compile as strings, and an absent match +// answers the empty array Node does. +const names = "a-b c".match(/[a-z]+/g) ?? []; +console.log(names.map((name) => name.toUpperCase()).join("+")); +const none = "123".match(/[a-z]+/g) ?? []; +console.log(none.length, JSON.stringify(none), names.length); + +// A declared optional array keeps its own type through the same default, +// and the fresh empty array is a value of its own (mutation, identity). +function fill(source: number[] | undefined, with_: number): number[] { + const list = source ?? []; + list.push(with_); + return list; +} +console.log(JSON.stringify(fill(undefined, 1)), JSON.stringify(fill([7, 8], 9))); +const first = fill(undefined, 2); +const second = fill(undefined, 3); +console.log(first !== second, JSON.stringify(first), JSON.stringify(second)); + +// A parenthesized default and one inside a longer expression. +const longest = ("abbbc".match(/b+/g) ?? []).length; +console.log(longest, `${JSON.stringify("xyz".match(/q/g) ?? [])}!`); diff --git a/tests/harness/corpus-costs/nullish-empty-array-default.ts.json b/tests/harness/corpus-costs/nullish-empty-array-default.ts.json new file mode 100644 index 000000000..b1c421307 --- /dev/null +++ b/tests/harness/corpus-costs/nullish-empty-array-default.ts.json @@ -0,0 +1 @@ +{ "plain": 922, "san": 1502 } From b3d69baae82a4ae214689c8373c0f5007d1d6f3b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:27:04 -0700 Subject: [PATCH 176/193] Narrow a stored union to the stdlib slot's arm instead of refusing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checked-dynamic JSON stringify (any record or array carrying an `unknown` field, so the runtime walker serializes it) answers `string | undefined` in the IR — its live walker can return undefined for a dyn root — while the checker types the call `string`. Reading a string member of that result (`JSON.stringify(toolsAdded).length` in a token estimator) then met the lib-boundary pass, which refused every union at a concrete slot with SC2003 "expected 'string', got 'string | undefined'". The slot's own type IS the site's proven type: tsc approved the call, so the stored union is the declaration's wider one (an absence arm the compiler itself carries, a stale capture, a widened inferred return). Coerce it with coerceToExpected's checked arm rule — the proven arm's payload comes out and any other arm throws the existing catchable TypeError — and keep the refusal for a union that does not carry the slot type as an arm. No compiling program changes IR: every program on this path refused before. Corpus: tests/corpus/lib-slot-union-arm-extraction.ts pins string members (length, slice, toUpperCase, indexOf, template interpolation, comparison and concatenation) of dyn-root JSON stringify results over arrays, nested records and empty collections. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lib-boundary.ts | 13 ++++++++ .../lib-slot-union-arm-extraction.ts.json | 6 ++++ tests/corpus/lib-slot-union-arm-extraction.ts | 33 +++++++++++++++++++ .../lib-slot-union-arm-extraction.ts.json | 1 + 4 files changed, 53 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json create mode 100644 tests/corpus/lib-slot-union-arm-extraction.ts create mode 100644 tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json diff --git a/packages/compiler/src/frontend/lowering/lib-boundary.ts b/packages/compiler/src/frontend/lowering/lib-boundary.ts index 8a264d346..06213531e 100644 --- a/packages/compiler/src/frontend/lowering/lib-boundary.ts +++ b/packages/compiler/src/frontend/lowering/lib-boundary.ts @@ -122,6 +122,19 @@ function coerceSlot(lowerer: Lowerer, arg: IrExpr, want: IrType, what: string): ); } if (arg.type.kind === "union" && want.kind !== "union" && !containsDynOrUnit(want)) { + // A stored union at a slot whose signature names one concrete type: the + // checker approved the call, so the SITE's type is that slot type and the + // stored union is the declaration's wider one — an absence arm the + // compiler itself carries (a checked-dynamic JSON stringify's + // `string | undefined`), a stale capture, a widened inferred return. + // That is coerceToExpected's checked ARM rule, not a refusal to lay the + // union out: the proven arm's payload comes out, every other arm throws + // the existing catchable TypeError (divergence 38 — sound narrowing + // never reaches them). A union with no such arm keeps the refusal. + if (lowerer.armTag(arg.type.unionId, want) >= 0) { + const narrowed = lowerer.coerceToExpected(arg, want); + if (typeEquals(narrowed.type, want)) return narrowed; + } lowerer.pushDiag(unionMismatchDiag(lowerer.fmt(want), lowerer.fmt(arg.type), arg.loc)); throw new PoisonError(); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json new file mode 100644 index 000000000..be3905a1b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/lib-slot-union-arm-extraction.ts" + ], + "diags": [] +} diff --git a/tests/corpus/lib-slot-union-arm-extraction.ts b/tests/corpus/lib-slot-union-arm-extraction.ts new file mode 100644 index 000000000..9e4960fc3 --- /dev/null +++ b/tests/corpus/lib-slot-union-arm-extraction.ts @@ -0,0 +1,33 @@ +// A checked-dynamic JSON stringify — a record or array carrying an `unknown` +// field — answers `string | undefined` in the IR (the runtime walker can +// return undefined for a dyn root) while the checker types the call `string`. +// Reading a string member of that result is the checked ARM extraction at the +// stdlib slot: the proven arm's payload comes out, and the absent arm would +// throw the catchable TypeError instead of the call being refused. + +interface Tool { + name: string; + parameters: unknown; +} + +function sizes(tools: Tool[], extra: { note: string; value: unknown }): string { + const text = JSON.stringify(tools); + let chars = 0; + chars += text.length; + if (tools.length > 0) chars += JSON.stringify(tools[0]).length; + chars += JSON.stringify(extra).slice(0, 24).length; + chars += JSON.stringify({ tools }).toUpperCase().slice(0, 12).length; + chars += JSON.stringify(tools).indexOf('"name"'); + chars += `raw:${JSON.stringify(extra)}`.length > 0 ? 1 : 0; + return `${chars} ${text.length} ${JSON.stringify({ tools }).slice(0, 12)}`; +} + +console.log(sizes([{ name: "alpha", parameters: { a: 1 } }], { note: "n", value: null })); +console.log(sizes([{ name: "beta", parameters: undefined }], { note: "empty", value: [1, 2] })); +console.log(sizes([], { note: "none", value: { nested: { deep: true } } })); +console.log(sizes([{ name: "gamma", parameters: "s" }], { note: "deep", value: { a: [1, { b: 2 }] } })); + +// The same string result feeding a comparison and a concatenation. +const one = JSON.stringify({ name: "x", parameters: 1 }); +const two = JSON.stringify({ parameters: 1, name: "x" }); +console.log(one === two, one < two, ("p" + one).length); diff --git a/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json b/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json new file mode 100644 index 000000000..9316b6552 --- /dev/null +++ b/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json @@ -0,0 +1 @@ +{ "plain": 1517, "san": 2343 } From c294d129d057a433ad00a2f37055eb7a205f581d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:00:52 -0700 Subject: [PATCH 177/193] Filter a union of arrays arm by arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.filter` on `A[] | B[]` refused with SC2020: `lowerArrayUnionHofCall` admitted only `map` and `forEach`. Under TS 5.2's callable-union rule the predicate runs over `A | B`, and the result element is the one the predicate names — `(b): b is TextContent` over a union of block arrays answers `TextContent[]` — which no single arm's element need equal. `filter` joins the same arm dispatch: each arm filters its own concrete array through the existing per-arm helper, and its survivors then pass through the checked conversion to the checker's result element, so a predicate that kept an element it does not describe throws instead of reinterpreting a value. An arm with no such conversion (a boolean predicate that names no narrower element) leaves the SC2020 fence in place. Typed programs are untouched: the gate only widens, and `map`/`forEach` keep their exact helper key, so the helpers a program already emitted stay byte-identical. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-callbacks.ts | 100 +++++++++++++++--- .../corpus/8530-union-array-filter.ts.json | 6 ++ .../test/union-array-filter-emission.test.ts | 90 ++++++++++++++++ tests/corpus/8530-union-array-filter.ts | 86 +++++++++++++++ .../8530-union-array-filter.ts.json | 1 + 5 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json create mode 100644 packages/compiler/test/union-array-filter-emission.test.ts create mode 100644 tests/corpus/8530-union-array-filter.ts create mode 100644 tests/harness/corpus-costs/8530-union-array-filter.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index d4183d5e5..ff4d9efaf 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -632,7 +632,14 @@ function arrayHofHelper( * original concrete array arm — never a copied `(T | U)[]` — and an * arm-specific function adapter wraps the element and receiver arguments * into the union-wide callback ABI. That preserves sparse-array reads, - * callback mutation, and the callback's third-argument identity. */ + * callback mutation, and the callback's third-argument identity. + * + * `filter` joins them: the kept elements are the checker's result element + * (`(b): b is TextContent` over `(TextContent | ImageContent)[] | + * (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm + * filters its own array and converts its survivors through the checked + * narrowing — a predicate that kept an element it does not describe throws + * the TypeError. An arm with no such conversion leaves the fence in place. */ export function lowerArrayUnionHofCall( lowerer: Lowerer, call: ts.CallExpression, @@ -640,7 +647,7 @@ export function lowerArrayUnionHofCall( receiverT: IrType & { kind: "union" }, ): IrExpr | null { const method = access.name.text; - if (method !== "map" && method !== "forEach") return null; + if (method !== "map" && method !== "forEach" && method !== "filter") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(receiverT.unionId); if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null; @@ -682,8 +689,45 @@ export function lowerArrayUnionHofCall( lowerer.badType(call, lowerer.typeOf(call)); } if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet); - const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID; - const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; + if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) { + if (fnRet.kind === "void") { + lowerer.unsupported( + "SC1090", + call, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } + lowerer.badType(call, lowerer.typeOf(call)); + } + // `map` stores the callback's answer; `filter` keeps the elements its + // predicate named. The kept element is the CHECKER's result element, which + // for a written type predicate is narrower than every arm's own: + // `(b): b is TextContent` over `(TextContent | ImageContent)[] | + // (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm + // filters its own concrete array and converts its survivors to that + // element through the checked narrowing — a predicate that kept an element + // it does not describe throws instead of misreading it. No conversion the + // arms cannot make honestly means the fence stays. + let outElem: IrType = VOID; + const narrowing: (string | null)[] = []; + if (method === "map") { + outElem = callbackArrayElement(lowerer, call, fnRet); + } else if (method === "filter") { + const result = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!result || result.kind !== "array") return null; + outElem = result.elem; + const typeText = filterTypeText(lowerer, call); + for (const array of arrays) { + if (typeEquals(array.elem, outElem)) { + narrowing.push(null); + continue; + } + const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call)); + if (helper === null) return null; + narrowing.push(helper); + } + } + const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem); for (const array of arrays) { const armFnT = funcOf( [arrayValueType(lowerer, array.elem), F64, array].slice(0, arity), @@ -700,6 +744,7 @@ export function lowerArrayUnionHofCall( arity, fnRet, outElem, + narrowing, locOf(call), ); return { @@ -716,24 +761,31 @@ export function lowerArrayUnionHofCall( function arrayUnionHofHelper( lowerer: Lowerer, - method: "map" | "forEach", + method: "map" | "forEach" | "filter", receiverT: IrType & { kind: "union" }, arrays: (IrType & { kind: "array" })[], callbackT: IrType & { kind: "func" }, arity: number, fnRet: IrType, outElem: IrType, + narrowing: (string | null)[], loc: SrcLoc, ): string { - const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; - const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`; + const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem); + // A filter's per-arm conversions are part of what the helper does, so two + // sites that share receiver, callback and result but convert differently + // keep their own helper. `map`/`forEach` never narrow, and leaving their + // key untouched keeps the helpers a program already emitted byte-identical. + const narrowingKey = + method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : ""; + const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%arr.union", key); lowerer.arrHofHelpers.set(key, name); const receiver = varRef("a.0", receiverT, loc); const callback = varRef("f.0", callbackT, loc); - const branch = (array: IrType & { kind: "array" }): IrStmt[] => { + const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => { const tag = lowerer.armTag(receiverT.unionId, array); const concrete: IrExpr = { kind: "unionNarrow", @@ -751,16 +803,38 @@ function arrayUnionHofHelper( if (!typeEquals(adapted.type, armFnT)) { throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting"); } - const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem); - const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc }; + const callee = arrayHofHelper( + lowerer, + method, + array.elem, + fnRet, + arity, + loc, + method === "filter" ? array.elem : outElem, + ); + const invoke: IrExpr = { + kind: "call", + callee, + args: [concrete, adapted], + type: method === "filter" ? arrayOf(array.elem) : resultT, + loc, + }; + // Survivors of this arm's own array, converted to the checker's result + // element. The helper throws for a kept element the predicate does not + // describe, so a lying predicate never reinterprets a value. + const armNarrow = narrowing[index] ?? null; + const answer: IrExpr = + armNarrow === null + ? invoke + : { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc }; return resultT.kind === "void" ? [ { kind: "exprStmt", expr: invoke, loc }, { kind: "return", value: null, loc }, ] - : [{ kind: "return", value: invoke, loc }]; + : [{ kind: "return", value: answer, loc }]; }; - let body = branch(arrays[arrays.length - 1]!); + let body = branch(arrays[arrays.length - 1]!, arrays.length - 1); for (let i = arrays.length - 2; i >= 0; i--) { const array = arrays[i]!; body = [ @@ -775,7 +849,7 @@ function arrayUnionHofHelper( type: BOOL, loc, }, - then: branch(array), + then: branch(array, i), else_: body, loc, }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json new file mode 100644 index 000000000..3ebcb0e28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8530-union-array-filter.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-filter-emission.test.ts b/packages/compiler/test/union-array-filter-emission.test.ts new file mode 100644 index 000000000..0ff81a032 --- /dev/null +++ b/packages/compiler/test/union-array-filter-emission.test.ts @@ -0,0 +1,90 @@ +// `.filter` on a union of arrays (`A[] | B[]`): the predicate runs over +// `A | B`, and the checker's result element is the arm the predicate names. +// The runtime value is one concrete arm's array, so the lowering must +// dispatch on that arm (unionIsTag), filter the arm's own array, and convert +// the survivors to the named element through the checked narrowing — never +// hand a union receiver to a static array intrinsic. +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, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface TextContent { type: 'text'; text: string }", + "interface ImageContent { type: 'image'; data: string }", + "interface ThinkingContent { type: 'thinking'; thinking: string }", + "type MessageContent =", + " | TextContent[]", + " | (ImageContent | TextContent)[]", + " | (ThinkingContent | TextContent)[];", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-union-array-filter-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function named(module: IrModule, part: string): string { + return JSON.stringify(module.functions.filter((fn) => fn.name.includes(part))); +} + +test("filter on a union of arrays dispatches on the arm", async () => { + const compiled = await compileToIr([ + "function texts(content: MessageContent): string {", + " return content", + " .filter((block): block is TextContent => block.type === 'text')", + " .map((block) => block.text)", + " .join(' ');", + "}", + "const blocks: MessageContent = [{ type: 'text', text: 'a' }];", + "console.log(texts(blocks));", + ]); + expect(compiled.diagnostics).toBe(""); + expect(compiled.ok).toBe(true); + const module = compiled.module!; + // The tag dispatch that reads the runtime arm… + const union = named(module, "%arr.union"); + expect(union).toContain("unionIsTag"); + // …and the checked narrowing each arm's survivors pass through, so a + // predicate that kept an element it does not describe throws. + expect(named(module, "%arr.narrow")).toContain("%narrow.checked"); +}); + +test("filter on a union of arrays keeps the fence without a narrowing", async () => { + // A predicate that names no narrower element answers the union-wide + // element; converting an arm's array to that element is a widening this + // lowering does not build, so the call stays a refusal instead of + // compiling to a different array layout. + const compiled = await compileToIr([ + "function counts(content: MessageContent): number {", + " return content.filter((block): boolean => block.type !== 'image').length;", + "}", + "console.log(counts([]));", + ]); + expect(compiled.ok).toBe(false); + expect(compiled.diagnostics).toContain("SC2020"); +}); diff --git a/tests/corpus/8530-union-array-filter.ts b/tests/corpus/8530-union-array-filter.ts new file mode 100644 index 000000000..0c1f74f93 --- /dev/null +++ b/tests/corpus/8530-union-array-filter.ts @@ -0,0 +1,86 @@ +// `.filter` on a UNION OF ARRAYS (`A[] | B[]`, TS 5.2's callable-union +// rule): the predicate runs over `A | B`, and the checker's result element +// is the one the predicate names — not any arm's own element. The runtime +// value is one concrete arm's array, so the filter dispatches on that arm, +// keeps the arm's survivors in order, and converts each survivor to the +// named element. +interface TextContent { + type: "text"; + text: string; +} +interface ImageContent { + type: "image"; + data: string; +} +interface ThinkingContent { + type: "thinking"; + thinking: string; +} +interface ToolCall { + type: "tool"; + name: string; +} +type MessageContent = + | TextContent[] + | (ImageContent | TextContent)[] + | (ThinkingContent | ToolCall | TextContent)[]; + +function textOf(content: MessageContent): string { + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +const contents: MessageContent[] = [ + [{ type: "text", text: "a" }, { type: "text", text: "b" }], + [ + { type: "image", data: "AA" }, + { type: "text", text: "c" }, + ], + [ + { type: "thinking", thinking: "t" }, + { type: "tool", name: "n" }, + { type: "text", text: "d" }, + ], +]; +for (const content of contents) console.log("text", textOf(content)); + +// A predicate written as a function EXPRESSION rather than an arrow: the +// same receiver, the same per-arm dispatch. +function expressions(content: MessageContent): string { + return content + .filter(function (block): block is TextContent { + return block.type === "text"; + }) + .map((block) => block.text) + .join(" "); +} +console.log("expr", expressions(contents[1]!), expressions(contents[2]!)); + +// The predicate sees the union-wide element, so an arm-specific test is +// legal; the survivors still convert per arm. +function longTexts(content: MessageContent): string[] { + return content + .filter((block): block is TextContent => block.type === "text" && block.text !== "c") + .map((block) => block.text); +} +console.log("long", longTexts(contents[1]!).join("+"), longTexts(contents[2]!).join("+")); + +// Node's `filter` reads the length once and visits elements in order; the +// arm dispatch must not change either, and the result is a fresh array. +let seen = 0; +function counted(content: MessageContent): number { + const kept = content.filter((block): block is TextContent => { + seen++; + return block.type === "text"; + }); + return kept.length; +} +console.log("counted", counted(contents[2]!), "seen", seen); +// The empty array is spelled with its arm's type: a bare `[]` literal +// assigned to this three-arm array union is a separate, unrelated gap (the +// literal's `number[]` element has no arm conversion), so it would obscure +// what this program pins. +const empty: MessageContent = [] as TextContent[]; +console.log("empty", textOf(empty), counted(empty), "seen", seen); diff --git a/tests/harness/corpus-costs/8530-union-array-filter.ts.json b/tests/harness/corpus-costs/8530-union-array-filter.ts.json new file mode 100644 index 000000000..b563858c9 --- /dev/null +++ b/tests/harness/corpus-costs/8530-union-array-filter.ts.json @@ -0,0 +1 @@ +{ "plain": 1900, "san": 2200 } From 0c6a6d1db55c7288d2fdc92e715eb2684b7a3e08 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:09:26 -0700 Subject: [PATCH 178/193] Lift a dynamic record field into a union destination A record field the checker types `any`/`unknown` lifts into a non-union destination through a dynCheck, but the union branch of widthLiftPlan searched only for a source arm, and a dyn source has no arm family. SC2002 therefore refused `details: any` into `Details | undefined`, a tool result's `structuredContent: unknown` into a JSON-value union, and the same field carried through a spread into an index-signature union member. The union branch now ends with that destination check: when no arm was selected, a dyn source takes the same canDynCheckTo extraction coerceToExpected already emits for a dyn value in a union slot. It sits after every arm rule, so every conversion that exists today (including a union that has its own dyn arm) keeps that plan unchanged; only the case that returned null before converts now. Co-Authored-By: Claude Opus 5.5 --- .../lowering/coercions/structural-plans.ts | 20 +++++ .../5798-record-field-dyn-into-union.ts.json | 6 ++ .../5798-record-field-dyn-into-union.ts | 85 +++++++++++++++++++ .../5798-record-field-dyn-into-union.ts.json | 1 + 4 files changed, 112 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json create mode 100644 tests/corpus/5798-record-field-dyn-into-union.ts create mode 100644 tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 2a1d75331..8f17827d5 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -160,6 +160,26 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width if (exact.length === 1) selected = exact; else if (exact.length === 0 && lossless.length === 1) selected = lossless; } + // An 'unknown' (dyn) source whose union destination has no dyn arm of + // its own (`details: any` into `Details | undefined`, a tool result's + // `structuredContent: unknown` into the JSON-value union): the checked + // extraction, the same dynCheck coerceToExpected performs for a dyn + // value in a union slot. A dyn source has no arm family, so every arm + // rule above has passed it by, and every plan that exists for a dyn + // source is the one above (its own arm) — this is exactly the case + // that returned null (SC2002) before, so no convertible value changes + // its lowering. + if ( + selected.length !== 1 && + src.kind === "dyn" && + canDynCheckTo( + dst, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + return { how: "dynOut" }; + } if (selected.length !== 1) return null; return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json new file mode 100644 index 000000000..54a7a2931 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5798-record-field-dyn-into-union.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5798-record-field-dyn-into-union.ts b/tests/corpus/5798-record-field-dyn-into-union.ts new file mode 100644 index 000000000..b273a89ce --- /dev/null +++ b/tests/corpus/5798-record-field-dyn-into-union.ts @@ -0,0 +1,85 @@ +// A record field the checker types `any`/`unknown` flowing into a slot whose +// declared type is a UNION: the checked extraction (dynCheck) is the +// conversion, exactly as for a dyn value in a top-level union slot. Before +// this, such a field only lifted into a NON-union destination, so a tool +// result's `details: unknown` could not reach `Details | undefined`, a +// `structuredContent: unknown` could not reach a JSON-value union, and a +// spread's dynamic key could not reach an index-signature member. +interface Details { + limit: number; + flag?: boolean; +} + +interface Wide { + label: string; + details: any; +} +interface Narrow { + label: string; + details: Details | undefined; +} + +function narrow(w: Wide): Narrow { + return w; +} + +type Json = string | number | boolean | null | { [key: string]: Json } | Json[]; +interface Payload { + text: string; + structuredContent: any; +} +interface JsonPayload { + text: string; + structuredContent: Json | undefined; +} + +function widen(p: Payload): JsonPayload { + return p; +} + +// A spread whose source carries the dynamic field: the same field lift. +interface Built { + content: string; + sections: any; +} +interface Message { + role: string; + content: string; + sections: { [key: string]: string | null } | undefined; + timestamp: number; +} +function fromState(flag: boolean): Built { + return { content: flag ? "on" : "off", sections: { preamble: "p" } }; +} +function message(flag: boolean): Message { + return { role: "system", ...fromState(flag), timestamp: 0 }; +} + +// A callback whose result parameter narrows the dynamic field back to a +// record type adapts into the slot's dynamic-details signature. +interface SlotResult { + content: { type: string; text: string }[]; + details: any; +} +interface ToolResult { + content: { type: string; text: string }[]; + details: { limit: number } | undefined; +} +type Renderer = (result: SlotResult, expanded: boolean) => string; +function format(result: ToolResult, expanded: boolean): string { + return `${result.content.length}:${result.details?.limit ?? "none"}:${expanded}`; +} +const render: Renderer = (result, expanded) => format(result, expanded); + +const wide: Wide = { label: "a", details: { limit: 3 } }; +const n = narrow(wide); +console.log(n.label, n.details === undefined ? "none" : n.details.limit); + +const p = widen({ text: "t", structuredContent: { nested: [1, true, null] } }); +console.log(p.text, JSON.stringify(p.structuredContent ?? null)); + +const m = message(true); +console.log(m.role, m.content, m.timestamp, m.sections?.["preamble"] ?? "-"); + +console.log(render({ content: [{ type: "text", text: "abc" }], details: { limit: 7 } }, true)); +console.log(render({ content: [], details: undefined }, false)); diff --git a/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json b/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } From 830ee3ddf3633b31ebcf00927df2cc4d1dc18762 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:09:31 -0700 Subject: [PATCH 179/193] Build a fresh object literal at an async record result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unannotated async method inside a contextually typed object literal is checked against the contextual signature's result, while its own inferred return type stays the returned literal's narrower record. The async return lowered the literal untyped and then coerced that wider record into the declared result, which refused on `content`: the wider content-part union array does not lift into the inferred text-record array. A ToolDefinition execute reached through a Record hit the same SC2002. A fresh object literal returned into a record result is not a thenable, so when the ordinary construction cannot convert it is now built at the result slot, in both the synchronous and the promise-adopting return paths. The field-set comparison also considers the literal's own checker type, not only the contextual type it was checked against. Both the return-path rule and the object-literal rule ask the same question first — does the record the ordinary path builds have a width plan into the result? — so a literal that converts today, and every value that is not a fresh literal, keeps its exact lowering. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 32 ++++++-- .../compiler/src/frontend/lowering/lowerer.ts | 43 ++++++++++- ...9-async-return-record-literal-slot.ts.json | 6 ++ .../5799-async-return-record-literal-slot.ts | 76 +++++++++++++++++++ ...9-async-return-record-literal-slot.ts.json | 1 + 5 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json create mode 100644 tests/corpus/5799-async-return-record-literal-slot.ts create mode 100644 tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7947f2830..32caedcbf 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1346,20 +1346,38 @@ export function lowerObjectLiteral( ) { const own = lowerer.shapes.get(type.shapeId)!; const target = lowerer.shapes.get(expected.shapeId)!; - if ( - !own.tuple && + // The field set to compare is the LITERAL's: its checker type. The + // contextual type a few lines up can be wider than what the literal + // writes (an unannotated method checked against a generic slot's + // signature: `{ content, details }` under a contextual + // `AgentToolResult` whose optional members the literal never + // mentions), and the destination still names the same fields as the + // literal, so the build can construct them directly. Only when the + // contextual build has no conversion into the destination at all: a + // value that converts today keeps its exact construction. + const ownType = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const ownChecked = + ownType?.kind === "record" && + ownType.shapeId !== type.shapeId && + lowerer.widthLiftPlan(type, expected) === null + ? lowerer.shapes.get(ownType.shapeId) + : undefined; + const fits = (from: IrRecordShape): boolean => + !from.tuple && !target.tuple && - !own.indexValue && + !from.indexValue && !target.indexValue && - !shapeHasAccessorSlots(own) && + !shapeHasAccessorSlots(from) && !shapeHasAccessorSlots(target) && target.fields.every( (field) => - own.fields.some((candidate) => candidate.name === field.name) || + from.fields.some((candidate) => candidate.name === field.name) || (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0), ) && - own.fields.every((field) => target.fields.some((candidate) => candidate.name === field.name)) - ) { + from.fields.every((field) => + target.fields.some((candidate) => candidate.name === field.name), + ); + if (fits(own) || (ownChecked !== undefined && fits(ownChecked))) { type = { kind: "record", shapeId: expected.shapeId }; } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7700b7406..ad7ca5fc5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -14204,9 +14204,49 @@ export class Lowerer { if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected); if (this.returnsFreshCollection(node) && expected.kind !== "void") return this.lowerExprExpecting(node, expected); + // A fresh object literal into a record result: the same + // destination-directed construction. An unannotated method inside a + // contextually typed literal is checked against the CONTEXTUAL + // signature's result while its own return type stays the literal's + // inferred (narrower) record — `ToolDefinition` + // methods returning `{ content, details }`. Building at the + // contextual type would then coerce that wider record into the + // declared result and refuse; the result slot is the layout. + if (this.returnsFreshRecord(node, expected)) return this.lowerExprExpecting(node, expected); return this.asyncReturnValueOf(node, this.lowerExpr(node), expected); } + /** A fresh object literal returned into a RECORD result whose ordinary + * construction cannot reach that result: not a thenable, so it is built + * at the result type like a synchronous return. An unannotated method + * inside a contextually typed literal is checked against the CONTEXTUAL + * signature's result while its own return type stays the literal's + * inferred (narrower) record — `ToolDefinition` methods + * returning `{ content, details }`. Building at the contextual type + * would then coerce that wider record into the declared result and + * refuse; the result slot is the layout. A literal that declares `then` + * is awaited instead, and a literal whose contextual build does convert + * keeps the ordinary lowering, so no convertible value changes. */ + private returnsFreshRecord(node: ts.Expression, expected: IrType): boolean { + if (expected.kind !== "record") return false; + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isObjectLiteralExpression(e)) return false; + if (this.checker.getPropertyOfType(this.typeOf(e), "then") !== undefined) return false; + // The record the ordinary path builds: the literal's context, with an + // async return's `T | PromiseLike` stripped to the awaited record + // (object-literals.ts does the same before mapping). + let context = this.checker.getContextualType(e) ?? this.typeOf(e); + if ( + context.isUnionType() && + ts.constituentTypes(context).some((t) => ts.symbolNameOf(t.getSymbol()) === "PromiseLike") + ) + context = this.checker.getAwaitedType(context) ?? context; + const built = this.mapTypeOf(context); + if (!built || built.kind !== "record") return false; + return this.widthLiftPlan(built, expected) === null; + } + /** An array literal or a `new Map(...)`, `new Set(...)` or `new * Array(...)` of the builtins: a fresh collection, never a thenable, so * an async return takes the declared result type as its construction @@ -14381,7 +14421,8 @@ export class Lowerer { if ( this.adoptsReturnedPromise() && !this.returnsLiteralIntoDyn(node, expected) && - !this.returnsFreshCollection(node) + !this.returnsFreshCollection(node) && + !this.returnsFreshRecord(node, expected) ) { // The async return lowers its operand untyped first (a promise // adopts before its payload converts); a maybe-promise union needs diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..11487dedc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5799-async-return-record-literal-slot.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5799-async-return-record-literal-slot.ts b/tests/corpus/5799-async-return-record-literal-slot.ts new file mode 100644 index 000000000..fa5824046 --- /dev/null +++ b/tests/corpus/5799-async-return-record-literal-slot.ts @@ -0,0 +1,76 @@ +// A tool definition whose `execute` is an unannotated async method inside a +// contextually typed object literal. tsc checks the method against the +// contextual signature's result (`AgentToolResult`) but INFERS its +// own return type from the returned literal (`{ content: { type, text }[]; +// details: { loaded } }`). The fresh literal must construct at the declared +// result slot, not at the contextual type it was checked against: the +// contextual type's `content` is the wider content-part union array, which +// no copy narrows back into the inferred element record. +interface TextContent { + type: string; + text: string; + textSignature?: string; +} +interface ImageContent { + type: string; + data: string; + mimeType: string; +} +interface Candidate { + name: string; + score: number; +} +interface Definition { + name: string; + search(query: string): Promise; + execute(id: string, query: string): Promise>; +} +interface Result { + content: (TextContent | ImageContent)[]; + details: D; + structuredContent?: unknown; + isError?: boolean; +} +interface Loaded { + loaded: string[]; +} + +function rank(query: string, names: string[]): Candidate[] { + return names.filter((name) => name.includes(query)).map((name) => ({ name, score: name.length })); +} + +function makeDefinition(names: string[]): Definition { + return { + name: "search", + async search(query: string) { + return { loaded: rank(query, names).map((candidate) => candidate.name) }; + }, + async execute(_id: string, query: string) { + return { + content: [{ type: "text", text: rank(query, names).map((c) => c.name).join(",") }], + details: { loaded: rank(query, names).map((c) => c.name) }, + }; + }, + }; +} + +// The same shape through a record of definitions, each with its own details. +type AnyDefinition = Definition; +function makeNamed(names: string[]): Record<"search", AnyDefinition> { + return { search: makeDefinition(names) }; +} + +const definition = makeDefinition(["find-file", "grep-tree", "read-file"]); +definition + .search("file") + .then((loaded) => { + console.log("search:", loaded.loaded.join(",")); + return definition.execute("call-1", "file"); + }) + .then((result) => { + console.log("execute:", result.content.length, result.content[0]!.type, result.details.loaded.join(",")); + return makeNamed(["read-file"]).search.execute("call-2", "file"); + }) + .then((second) => { + console.log("second:", second.content.length, second.details.loaded.join(",")); + }); diff --git a/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } From 887aa774e0ac689da9c8e956c820f264fe15f582 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:58:39 -0700 Subject: [PATCH 180/193] Build an empty array literal in a union of array slots at its first arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const message: M = { content: [] }` with `content: TextContent[] | ImageContent[]` threw at run time, `a 'number[]' value is not representable in the target union`. tsc types an empty literal `never[]`, whose element has no representation of its own: the literal mapped through never's f64 element as `number[]`, then the union slot's retag found no width lift into either array arm and took the stranded-source trap. An empty array carries no element to select an arm by, and it is the same value whichever array family stores it, so the literal is built as the union's FIRST array arm — the rule a lone array slot already states, and the one the ternary arm and the `?? []` default apply. Both the IR slot (lowerExprExpecting) and the literal's own type mapping (lowerArrayLiteral) now state it for any number of array arms; a single array arm keeps the identical lowering it had. A `never[]` VALUE (an explicitly declared variable) still maps to `number[]` and keeps its refusal: mapping never's element globally is a different change. Corpus: tests/corpus/empty-array-union-slot.ts pins the nested record field, declarations and reassignment, declared and async returns, ternary arms (one and both empty), arguments and default parameters, deeper nesting, a class field, an optional arm, array/Set/Map element slots, a callback argument, an assertion, and a fresh array per evaluation. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/array-literals.ts | 10 +- .../compiler/src/frontend/lowering/lowerer.ts | 9 ++ .../corpus/empty-array-union-slot.ts.json | 6 + .../test/union-array-lowering.test.ts | 22 ++++ tests/corpus/empty-array-union-slot.ts | 106 ++++++++++++++++++ .../empty-array-union-slot.ts.json | 1 + 6 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json create mode 100644 tests/corpus/empty-array-union-slot.ts create mode 100644 tests/harness/corpus-costs/empty-array-union-slot.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 9545df829..29ddf3aac 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -208,14 +208,14 @@ export function lowerArrayLiteral( // []`, the `[]` default argument against `string[] | undefined`) has // no useful own type — tsc infers `never[]`, whose f64-element // mapping is a representation for the uninhabited, not the slot's - // arm. When the contextual union has exactly ONE array arm there is - // no ambiguity: build the literal as that arm and let the slot's - // coercion wrap it. Two array arms would need tsc's own best-fit - // choice — none arises in practice; the fence stands. + // arm. Build the literal as the union's FIRST array arm and let the + // slot's coercion wrap it: an empty array is the same value whichever + // array family stores it, so several array arms raise no ambiguity — + // the rule a lone array slot already states. if (ctxUnion && expr.elements.nodes.length === 0) { const def = lowerer.unions.get(ctxUnion.unionId); const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length === 1) mapped = arrayArms[0]!; + if (arrayArms.length >= 1) mapped = arrayArms[0]!; } // A NON-EMPTY literal whose own type has no static home (the JS dyn // fallback — a null/dyn mapping, or an inference that degraded to a diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ad7ca5fc5..1fde89a55 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -14044,6 +14044,15 @@ export class Lowerer { ); } } + // An EMPTY literal carries no element to select an arm by: tsc + // infers `never[]`, whose f64-element mapping is the uninhabited + // element's representation, not one of the slot's arms. Every empty + // array is the same value whichever array arm stores it, so the + // literal is built as the union's FIRST array arm — the empty + // literal rule a lone array slot already states, and the same one + // the ternary arm and the `?? []` default apply. + if (!arm && arms.length > 1 && x.elements.nodes.length === 0) + arm = arms.find((candidate) => candidate.kind === "array"); if (arm) { const built = this.lowerArrayLiteral( x, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json new file mode 100644 index 000000000..1e26cfe54 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/empty-array-union-slot.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-lowering.test.ts b/packages/compiler/test/union-array-lowering.test.ts index 2830c6fdf..09ef0993d 100644 --- a/packages/compiler/test/union-array-lowering.test.ts +++ b/packages/compiler/test/union-array-lowering.test.ts @@ -139,3 +139,25 @@ export {}; if (!result.ok) return; expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0); }); + +test("an empty array literal takes the union's first array arm instead of trapping", async () => { + const result = await lower(` +type TextContent = { type: "text"; text: string }; +type ImageContent = { type: "image"; url: string }; +interface Message { + content: TextContent[] | ImageContent[]; +} +const message: Message = { content: [] }; +function lists(): string[] | number[] { + return []; +} +console.log(JSON.stringify(message.content), JSON.stringify(lists())); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + // An empty literal carries no element of its own, so it is built as the + // union's first array arm: no stranded-source trap and no element rebuild. + expect(helpers(result.ir, "%union.strand")).toEqual([]); + expect(helpers(result.ir, "%union.arr")).toEqual([]); +}); diff --git a/tests/corpus/empty-array-union-slot.ts b/tests/corpus/empty-array-union-slot.ts new file mode 100644 index 000000000..ee6809498 --- /dev/null +++ b/tests/corpus/empty-array-union-slot.ts @@ -0,0 +1,106 @@ +// An EMPTY array literal in a union of array slots (`content: TextContent[] | +// ImageContent[]`, `string[] | number[]`). tsc types the literal `never[]`, an +// element with no representation of its own: the literal used to lower as +// never's f64-element array (`number[]`) and the slot's retag threw +// "a 'number[]' value is not representable in the target union". An empty +// array is the same value whichever array family stores it, so the literal is +// built as the union's first array arm — the rule a lone array slot states — +// and every shape below reaches Node's answer. +type TextContent = { type: "text"; text: string }; +type ImageContent = { type: "image"; url: string }; +interface Message { + role: string; + content: TextContent[] | ImageContent[]; +} + +// A nested record field: the reported shape. +const empty: Message = { role: "user", content: [] }; +console.log(JSON.stringify(empty)); + +// A declaration, its initialization and a later reassignment. +let wide: string[] | number[] = []; +console.log(JSON.stringify(wide)); +wide = [1, 2]; +console.log(JSON.stringify(wide)); +wide = []; +console.log(JSON.stringify(wide)); + +// A declared return, a ternary arm, and an async promise's payload. +function none(): string[] | number[] { + return []; +} +console.log(JSON.stringify(none()), none() !== none()); + +function either(take: boolean): string[] | number[] { + return take ? [] : [1, 2]; +} +console.log(JSON.stringify(either(true)), JSON.stringify(either(false))); + +function bothWays(): string[] | number[] { + return false ? [] : []; +} +console.log(JSON.stringify(bothWays())); + +async function settled(): Promise { + return []; +} +void settled().then((value) => console.log("settled", JSON.stringify(value))); + +// An argument slot, and a default parameter that is itself the slot. +function report(label: string, value: string[] | number[]): void { + console.log(label, JSON.stringify(value)); +} +report("argument", []); +report("argument-filled", ["x"]); + +function withDefault(value: string[] | number[] = []): void { + console.log("default", JSON.stringify(value)); +} +withDefault(); +withDefault([3]); + +// Deeper nesting, a class field, and a discriminated record's field. +interface Nested { + inner: { list: readonly string[] | readonly number[]; tag: string }; +} +const nested: Nested = { inner: { list: [], tag: "t" } }; +console.log(JSON.stringify(nested)); + +class Store { + lists: string[] | number[] = []; +} +const store = new Store(); +console.log("class", JSON.stringify(store.lists), store.lists !== undefined); + +// An optional arm (`| undefined`) keeps the empty literal on its array arm. +const maybe: string[] | number[] | undefined = []; +console.log("maybe", maybe !== undefined, JSON.stringify(maybe)); + +// The empty literal as an element of an array, and through collections whose +// element slot is the union. +const rows: (string[] | number[])[] = [[], [1, 2], []]; +console.log("rows", JSON.stringify(rows)); +const grouped = new Set(); +grouped.add([]); +grouped.add(["s"]); +console.log("set", JSON.stringify([...grouped])); +const byKey = new Map(); +byKey.set("a", []); +byKey.set("b", [7]); +console.log("map", JSON.stringify([...byKey.entries()])); + +// A callback argument, and the assertion form of the same value. +function apply(fn: (value: string[] | number[]) => string): string { + return fn([]); +} +console.log("callback", apply((value) => JSON.stringify(value))); +const asserted = { role: "tool", content: [] } as Message; +console.log("asserted", JSON.stringify(asserted)); + +// Every evaluation builds a fresh empty array, as Node does. +function fresh(): string[] | number[] { + return []; +} +const first = fresh(); +const second = fresh(); +console.log("fresh", first !== second, JSON.stringify(first), JSON.stringify(second)); diff --git a/tests/harness/corpus-costs/empty-array-union-slot.ts.json b/tests/harness/corpus-costs/empty-array-union-slot.ts.json new file mode 100644 index 000000000..b7f02e592 --- /dev/null +++ b/tests/harness/corpus-costs/empty-array-union-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3400 } From eabcbdb53398709319b7f235824bc3f826fee7ab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 17:59:41 -0700 Subject: [PATCH 181/193] State the empty-array arm rule once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three sites that give an empty `[]` literal a home — a union slot, a contextual union, and the `??` default — each carried their own copy of the rule and each tested it slightly differently (element count here, a `never[]` own type there), so a later fix could drift one from the others. emptyArrayLiteralArm states it once: an empty literal has no element to select an arm by, so it builds as the destination's first array arm. Every site keeps the lowering it had. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/array-literals.ts | 36 ++++++++++++++----- .../frontend/lowering/expressions/binary.ts | 36 +++++++------------ .../compiler/src/frontend/lowering/lowerer.ts | 14 +++----- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 29ddf3aac..aa23fb0a2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -38,6 +38,28 @@ import { arrayValueStore, primitiveElement } from "../array-values.js"; import { classGeneratorIterable } from "../class-iterables.js"; import { iteratorCanStep } from "../iterator-consumption.js"; +/** The arm an EMPTY `[]` literal builds as: `arms`' first array arm, or + * null when the literal is not empty (an element selects the arm) or no + * arm is an array. + * + * An empty literal carries no element of its own — tsc infers `never[]`, + * whose f64-element mapping is the uninhabited element's representation, + * not one of the destination's arms — and every empty array is the same + * value whichever array family stores it, so several array arms raise no + * ambiguity. This is the ONE statement of that rule: a union slot, a + * contextual union, and the `??` default all ask it. A lone array slot + * states the same thing without asking. */ +export function emptyArrayLiteralArm( + node: ts.Expression, + arms: readonly IrType[], +): (IrType & { kind: "array" }) | null { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return null; + const arm = arms.find((candidate) => candidate.kind === "array"); + return (arm as (IrType & { kind: "array" }) | undefined) ?? null; +} + /** JavaScript: whether `expr` is or holds an existing checked-dynamic * object — a binding stored as a dyn value whose checker type is an * object, or an array or object literal with such an element. Building @@ -206,16 +228,12 @@ export function lowerArrayLiteral( mapped = arrayOf(unitOnlyUnion(lowerer.unions)); // An EMPTY literal in a union slot (`const t: string[] | undefined = // []`, the `[]` default argument against `string[] | undefined`) has - // no useful own type — tsc infers `never[]`, whose f64-element - // mapping is a representation for the uninhabited, not the slot's - // arm. Build the literal as the union's FIRST array arm and let the - // slot's coercion wrap it: an empty array is the same value whichever - // array family stores it, so several array arms raise no ambiguity — - // the rule a lone array slot already states. - if (ctxUnion && expr.elements.nodes.length === 0) { + // no useful own type: it builds as the union's first array arm (the + // emptyArrayLiteralArm rule) and the slot's coercion wraps it. + if (ctxUnion) { const def = lowerer.unions.get(ctxUnion.unionId); - const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length >= 1) mapped = arrayArms[0]!; + const arm = emptyArrayLiteralArm(expr, def?.arms ?? []); + if (arm) mapped = arm; } // A NON-EMPTY literal whose own type has no static home (the JS dyn // fallback — a null/dyn mapping, or an inference that degraded to a diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index a6d7ad674..c204b40f4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -73,6 +73,7 @@ import { nsWritableTarget } from "../lower-namespaces.js"; import { arrayValueStore, tryLowerBytesReadNumberEquality } from "../array-values.js"; import { cjsExportMemberWriteGlobalOf, cjsInFileExportGlobalOf } from "../cjs-exports-object.js"; import { lowerUnionFieldWrite } from "./union-fields.js"; +import { emptyArrayLiteralArm } from "./array-literals.js"; import { lowerAbstractEquality } from "../abstract-equality.js"; import { lowerInExpression } from "./relational.js"; import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js"; @@ -380,19 +381,6 @@ function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boole }); } -/** An empty `[]` literal whose own type is `never[]` (no contextual array - * type supplied one): the `??` default of an optional array read. */ -function emptyNeverArrayLiteral(lowerer: Lowerer, node: ts.Expression): boolean { - let value = node; - while (ts.isParenthesizedExpression(value)) value = value.expression; - if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return false; - const own = lowerer.typeOf(value); - return ( - lowerer.checker.isArrayType(own) && - (lowerer.checker.getTypeArguments(own as ts.TypeReference)[0]!.flags & ts.TypeFlags.Never) !== 0 - ); -} - export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -445,21 +433,21 @@ export function lowerNullishPair( return { kind: "nullish", left, right, type: rest[0]!, loc }; } // An EMPTY array-literal default (`''.match(re) ?? []`) has no element of - // its own: tsc infers `never[]`, whose f64-element mapping the whole - // expression keeps as a second array arm (`RegExpMatchArray | number[]`), - // a result no single array slot can receive. It adopts the left's present - // array arm instead — the empty-literal rule the union slot and the - // ternary arm already state — so `?? []` answers the left's array type. - // A JavaScript `[]` is `any[]` and keeps the dynamic path. + // its own, so it builds as the left's array arm (the emptyArrayLiteralArm + // rule the union slot and the contextual union share) and the whole + // expression answers the left's array type instead of the checker's + // `never[]`-flavored second arm. A JavaScript `[]` is `any[]` and keeps + // the dynamic path. if ( rest.length === 1 && rest[0]!.kind === "array" && - !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) && - emptyNeverArrayLiteral(lowerer, expr.right) + !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) ) { - const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); - if (typeEquals(right.type, rest[0]!)) - return { kind: "nullish", left, right, type: rest[0]!, loc }; + const arm = emptyArrayLiteralArm(expr.right, rest); + if (arm) { + const right = lowerer.lowerExprExpecting(expr.right, arm); + if (typeEquals(right.type, arm)) return { kind: "nullish", left, right, type: arm, loc }; + } } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 1fde89a55..53849353e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -781,6 +781,7 @@ import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { partialAssertionFields } from "./partial-assertions.js"; +import { emptyArrayLiteralArm } from "./expressions/array-literals.js"; import { lowerTernary } from "./lower-exprs.js"; import { maybePromiseReturnStmt, @@ -14044,15 +14045,10 @@ export class Lowerer { ); } } - // An EMPTY literal carries no element to select an arm by: tsc - // infers `never[]`, whose f64-element mapping is the uninhabited - // element's representation, not one of the slot's arms. Every empty - // array is the same value whichever array arm stores it, so the - // literal is built as the union's FIRST array arm — the empty - // literal rule a lone array slot already states, and the same one - // the ternary arm and the `?? []` default apply. - if (!arm && arms.length > 1 && x.elements.nodes.length === 0) - arm = arms.find((candidate) => candidate.kind === "array"); + // An EMPTY literal carries no element to select an arm by: it + // builds as the union's first array arm (the emptyArrayLiteralArm + // rule the contextual union and the `?? []` default share). + if (!arm) arm = emptyArrayLiteralArm(x, arms) ?? undefined; if (arm) { const built = this.lowerArrayLiteral( x, From 43c72a8e9bb3a7bf6f740f47ea2d8ddaa8d8d1df Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:18:04 -0500 Subject: [PATCH 182/193] Save the receiver and key locals of a literal-union keyed write --- .../lowering/expressions/element-access.ts | 4 ++-- tests/corpus/record-literal-key-unions.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 85be6bb53..c94bfa802 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -2610,14 +2610,14 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): // A key whose literal type names only declared fields of one type // writes the field it names. JavaScript evaluates the receiver, then // the key, then the value; the dispatch reads the saved receiver and - // key once per named field. + // key once per named field. Locals are saved too, because the key or + // the value can reassign them. const named = litKey === null ? sameTypedFieldKeys(lowerer, shape, target.argumentExpression) : null; if (named && !shape.fields.every((f) => typeEquals(f.type, named.fieldType))) { const loc = locOf(expr); const body: IrStmt[] = []; const save = (value: IrExpr, name: string): IrExpr => { - if (value.kind === "varRef") return value; const temp = lowerer.declareHiddenLocal(name, value.type); body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); return varRef(temp.id, temp.type, loc); diff --git a/tests/corpus/record-literal-key-unions.ts b/tests/corpus/record-literal-key-unions.ts index afe328e98..cb9336455 100644 --- a/tests/corpus/record-literal-key-unions.ts +++ b/tests/corpus/record-literal-key-unions.ts @@ -76,3 +76,23 @@ function only(s: Stats, key: "bytes"): number { return s[key]; } console.log(only(stats[3]!, "bytes")); + +// A key or value that reassigns the receiver or key local: the write goes +// to the receiver and key evaluated before them. +let target = stats[0]!; +let slot: Counter = "hits"; +target[slot] = ((slot = "misses"), 500); +console.log(stats[0]!.hits, stats[0]!.misses, slot); +target[slot] = ((target = stats[2]!), 600); +console.log(stats[0]!.misses, stats[2]!.misses, target === stats[2]); +function retarget(): Counter { + target = stats[3]!; + return "bytes"; +} +target = stats[1]!; +target[retarget()] = 700; +console.log(stats[1]!.bytes, stats[3]!.bytes === 700); +target = stats[0]!; +slot = "hits"; +target[slot] += ((slot = "bytes"), (target = stats[1]!), 1); +console.log(stats[0]!.hits, stats[0]!.bytes, stats[1]!.hits); From ee621414c69c5f00d9889a94c2d2eaa7d61a2ae2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:26:24 -0500 Subject: [PATCH 183/193] Resolve relative import() specifiers as URLs for the not-found rejection probeNodeFileImportRefusal resolved a relative or absolute specifier as a filesystem path, so a percent escape, a query or a fragment became part of the path: "./a%20b.mjs" was looked up as "a%20b.mjs", and "./x.mjs?v=1" of an existing file rejected. It now resolves the specifier as a URL against the importer's file URL, as Node's ESM resolution does: the message names the decoded path without the query or fragment, and the error's url is the resolved URL with them. A path ending in "/", an encoded "/" or "\" and a malformed escape, which Node refuses with other errors, keep the current lowering. An absolute specifier that CommonJS resolution finds (for example a file named without its extension) gets Node's "Did you mean to import" hint. Node resolves the hint of a relative specifier against the process's working directory, so relative specifiers get none. npm-file-import.test.ts compares the probe with Node's import() rejections for 29 specifiers, and the dynamic-import-not-found corpus program covers an escaped specifier with a query and fragment. --- .../src/frontend/npm-file-import.test.ts | 83 ++++++++++++++++++ packages/compiler/src/frontend/npm.ts | 85 ++++++++++++++++--- tests/corpus/dynamic-import-not-found.mjs | 7 +- 3 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-file-import.test.ts diff --git a/packages/compiler/src/frontend/npm-file-import.test.ts b/packages/compiler/src/frontend/npm-file-import.test.ts new file mode 100644 index 000000000..daa0270ac --- /dev/null +++ b/packages/compiler/src/frontend/npm-file-import.test.ts @@ -0,0 +1,83 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { expect, test } from "vitest"; +import { probeNodeFileImportRefusal } from "./npm.js"; + +// probeNodeFileImportRefusal against Node itself: for each specifier, Node's +// import() from the same importer either rejects with ERR_MODULE_NOT_FOUND, +// whose code, message and url the probe must reproduce, or does something +// else (loads, or refuses with another error), where the probe answers null. +// Node runs in an empty directory: it resolves a relative specifier's +// "Did you mean" hint against its working directory, so there it adds none. +test("relative and absolute import() refusals match Node's", () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-file-import-"))); + writeFileSync(join(dir, "has space.mjs"), "export {};\n"); + writeFileSync(join(dir, "plain.mjs"), "export {};\n"); + writeFileSync(join(dir, "lit%20pct.mjs"), "export {};\n"); + writeFileSync(join(dir, "helper.js"), "export {};\n"); + mkdirSync(join(dir, "sub dir")); + mkdirSync(join(dir, "pkgdir")); + writeFileSync(join(dir, "pkgdir/package.json"), JSON.stringify({ main: "entry" })); + writeFileSync(join(dir, "pkgdir/entry.js"), "export {};\n"); + mkdirSync(join(dir, "indexed")); + writeFileSync(join(dir, "indexed/index.json"), "{}\n"); + mkdirSync(join(dir, "empty")); + const importer = join(dir, "main.mjs"); + writeFileSync( + importer, + `const results = []; +for (const specifier of JSON.parse(process.argv[2])) { + try { + await import(specifier); + results.push(null); + } catch (error) { + results.push(error.code === "ERR_MODULE_NOT_FOUND" ? { code: error.code, message: error.message, url: error.url } : null); + } +} +process.stdout.write(JSON.stringify(results)); +`, + ); + const specifiers = [ + "./no-such-file.mjs", + "./no%20such.mjs", + "./has%20space.mjs", + "./has space.mjs", + "./lit%20pct.mjs", + "./lit%2520pct.mjs", + "./plain.mjs?x=1", + "./plain.mjs#frag", + "./missing.mjs?x=1", + "./missing.mjs#frag", + "./sub%2Fx.mjs", + "./sub%5Cx.mjs", + "./bad%zz.mjs", + "./sub dir/", + "./no-such-dir/", + "./helper", + "./pkgdir", + "./pkgdir/", + "./indexed", + `../${basename(dir)}/missing.mjs`, + `../${basename(dir)}/helper`, + `${dir}/nope.mjs#h`, + `${dir}/has%20space.mjs`, + `${dir}/helper`, + `${dir}/helper?q`, + `${dir}/pkgdir/entry`, + `${dir}/lit%20pct.mjs`, + `${dir}/no-such`, + `${dir}/indexed/`, + ]; + const node = JSON.parse( + execFileSync(process.execPath, [importer, JSON.stringify(specifiers)], { + cwd: join(dir, "empty"), + encoding: "utf8", + }), + ) as unknown[]; + const probed = specifiers.map((specifier) => probeNodeFileImportRefusal(importer, specifier)); + expect(Object.fromEntries(specifiers.map((s, i) => [s, probed[i]]))).toEqual( + Object.fromEntries(specifiers.map((s, i) => [s, node[i]])), + ); +}); diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index fff437ac0..c525eb9f6 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -1,6 +1,6 @@ /** Node package resolution shared by preflight and native module introspection. */ import { dirname, join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js"; import { trackedDirectoryExists, @@ -145,28 +145,85 @@ export function probeNodeImportRefusal( /** Node's import() refusal for a relative or absolute specifier whose * target does not exist: ERR_MODULE_NOT_FOUND naming the resolved path, with - * its file URL. An existing file or directory answers null, as do other - * specifier forms. ESM resolution adds no extension, so the path is the - * specifier resolved against the importer's directory. */ + * the resolved URL. ESM resolution reads the specifier as a URL relative to + * the importer's file URL and adds no extension: percent escapes are + * decoded in the path, and a query or fragment stays in the URL only. An + * existing file or directory answers null, as do other specifier forms and + * the specifiers Node refuses with another error (a path ending in "/", an + * encoded "/" or "\", a malformed escape). */ export function probeNodeFileImportRefusal( fromFile: string, specifier: string, host: NpmGraphHost = realNpmGraphHost, ): NodeImportRefusal | null { - const relative = + if (!isRelativeImportSpecifier(specifier) && !specifier.startsWith("/")) return null; + const importer = resolve(fromFile); + let url: URL; + let target: string; + try { + url = new URL(specifier, pathToFileURL(importer)); + if (url.pathname.endsWith("/") || /%2f|%5c/i.test(url.pathname)) return null; + target = fileURLToPath(url); + } catch { + return null; + } + if (host.isFile(target) || host.isDirectory(target)) return null; + const hint = specifier.startsWith("/") ? commonJsResolveFile(specifier, host) : null; + return { + code: "ERR_MODULE_NOT_FOUND", + message: + `Cannot find module '${target}' imported from ${importer}` + + (hint === null || hint === specifier + ? "" + : `\nDid you mean to import ${JSON.stringify(hint)}?`), + url: url.href, + }; +} + +function isRelativeImportSpecifier(specifier: string): boolean { + return ( specifier.startsWith("./") || specifier.startsWith("../") || specifier === "." || - specifier === ".."; - if (!relative && !specifier.startsWith("/")) return null; - const importer = resolve(fromFile); - const target = resolve(dirname(importer), specifier); - if (host.isFile(target) || host.isDirectory(target)) return null; - return { - code: "ERR_MODULE_NOT_FOUND", - message: `Cannot find module '${target}' imported from ${importer}`, - url: pathToFileURL(target).href, + specifier === ".." + ); +} + +/* Node's ERR_MODULE_NOT_FOUND hint names the file CommonJS resolution of + * the literal specifier finds: the exact file, the .js/.json/.node + * extensions, or a directory's package.json main or index. Node resolves a + * relative specifier for the hint against the process's working directory, + * which the compiled program only knows when it runs, so only an absolute + * specifier gets the hint here; the found path is the hint itself. */ +const COMMONJS_EXTENSIONS = [".js", ".json", ".node"]; + +function commonJsResolveFile(request: string, host: NpmGraphHost): string | null { + const file = (path: string): string | null => (host.isFile(path) ? host.realpath(path) : null); + const withExtensions = (path: string): string | null => { + for (const ext of COMMONJS_EXTENSIONS) { + const found = file(path + ext); + if (found !== null) return found; + } + return null; }; + const found = file(request) ?? withExtensions(request); + if (found !== null) return found; + if (!host.isDirectory(request)) return null; + const manifest = host.readFile(join(request, "package.json")); + if (manifest !== null) { + let main: unknown; + try { + main = (JSON.parse(manifest) as PkgJson).main; + } catch { + main = undefined; + } + if (typeof main === "string" && main !== "") { + const entry = resolve(request, main); + const found = file(entry) ?? withExtensions(entry) ?? withExtensions(join(entry, "index")); + if (found !== null) return found; + } + } + return withExtensions(join(request, "index")); } export function probeNodeRequireRefusal( diff --git a/tests/corpus/dynamic-import-not-found.mjs b/tests/corpus/dynamic-import-not-found.mjs index 2f1abf7e4..757eae920 100644 --- a/tests/corpus/dynamic-import-not-found.mjs +++ b/tests/corpus/dynamic-import-not-found.mjs @@ -1,8 +1,10 @@ // import() of a specifier nothing resolves rejects with Node's // ERR_MODULE_NOT_FOUND when the call runs: a package nothing installed // provides, and a relative path to a file that does not exist, whose error -// also carries the missing file's URL. The program itself loads, and each -// call rejects again when repeated. +// also carries the missing file's URL. The specifier is a URL: its escapes +// are decoded in the message's path, and its query and fragment stay in +// the url only. The program itself loads, and each call rejects again when +// repeated. const dirs = (text) => text.replace(/\/[^\s']*\//g, "/"); async function probe(label, load) { @@ -23,4 +25,5 @@ await probe("scoped", () => import("@nobody/no-such-package")); await probe("relative", () => import("./no-such-file.mjs")); await probe("relative again", () => import("./no-such-file.mjs")); await probe("parent", () => import("../no-such-dir/no-such-file.js")); +await probe("escaped", () => import("./no%20such%20file.mjs?v=1#top")); console.log("end"); From 141464a6f7e930e74202a19d654df8aa9b9093a0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:43:38 -0500 Subject: [PATCH 184/193] Encode plainLoops in the generated IR codec `IrFunction.plainLoops` was missing from codec.generated.ts, so IR read back through the binary codec lost it. Regenerated with scripts/generate-ir-codec.mjs. --- packages/compiler/src/ir/codec.generated.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index 064757b5e..e576b30e2 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "6493ba8347f973e2"; +export const IR_CODEC_LAYOUT = "5940048d92e96642"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -25,7 +25,7 @@ export function decodeIrFunction(r: IrReader): IrFunction { } const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"]; -const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; +const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"]; const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"]; const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"]; @@ -439,6 +439,11 @@ function encIrFunction(w: IrWriter, v: IrFunction): void { w.byte(1); w.str(v.fastRead); } + if (v.plainLoops === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.plainLoops); + } if (v.async === undefined) w.byte(0); else { w.byte(1); @@ -474,11 +479,12 @@ function decIrFunction(r: IrReader): IrFunction { const f10 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); const f11 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); const f12 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); - const f13 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); - const f14 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); - const f15 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); - const f16 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); - const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { async: f13 } : {}), ...(f14 !== undefined ? { generator: f14 } : {}), ...(f15 !== undefined ? { asyncCacheGlobal: f15 } : {}), ...(f16 !== undefined ? { asyncCycleCacheGlobal: f16 } : {}) }; + const f13 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable); + const f14 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); + const f15 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); + const f16 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); + const f17 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); + const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { plainLoops: f13 } : {}), ...(f14 !== undefined ? { async: f14 } : {}), ...(f15 !== undefined ? { generator: f15 } : {}), ...(f16 !== undefined ? { asyncCacheGlobal: f16 } : {}), ...(f17 !== undefined ? { asyncCycleCacheGlobal: f17 } : {}) }; return o; } From 7befe5d506509e003a56c4ad37e68b41b49595a2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:52:03 -0500 Subject: [PATCH 185/193] Regenerate the IR codec for partial record views codec.generated.ts was not regenerated when partialView was added to IR expressions, so IR read through the binary codec dropped the field and the stale-codec check failed. --- packages/compiler/src/ir/codec.generated.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index 557f85592..4dc292756 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "a1813b4b2a32bfd0"; +export const IR_CODEC_LAYOUT = "b964edeec6535396"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -150,7 +150,7 @@ const KEYS122: readonly string[] = ["kind","fnName","captures","adapts","type"," const KEYS123: readonly string[] = ["kind","value","type","loc"]; const KEYS124: readonly string[] = ["kind","elems","type","loc"]; const KEYS125: readonly string[] = ["kind","callee","receiver","calleeName","calleeNameValue","args","spreads","type","loc"]; -const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","inferredSite","origin","type","loc"]; +const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","partialView","inferredSite","origin","type","loc"]; const KEYS127: readonly string[] = ["kind","value","spelling","firstProp","type","loc"]; const KEYS128: readonly string[] = ["kind","value","type","loc","liveRef","fnName","fnLength"]; const KEYS129: readonly string[] = ["kind","key","negated","value","type","loc"]; @@ -2923,6 +2923,11 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { w.byte(1); w.bool(v.exact); } + if (v.partialView === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.partialView); + } if (v.inferredSite === undefined) w.byte(0); else { w.byte(1); @@ -3953,11 +3958,12 @@ function decIrExpr(r: IrReader): IrExpr { const f1 = r.tag(2, "IrFunction.body[].arr.preserveRefs") === 0 ? undefined : (r.bool() as NonNullable["preserveRefs"]>); const f2 = r.tag(2, "IrFunction.body[].arr.adopt") === 0 ? undefined : (r.bool() as NonNullable["adopt"]>); const f3 = r.tag(2, "IrFunction.body[].arr.exact") === 0 ? undefined : (r.bool() as NonNullable["exact"]>); - const f4 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str(); - const f5 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num(); - const f6 = decIrType(r); - const f7 = decSrcLoc(r); - const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { inferredSite: f4 } : {}), ...(f5 !== undefined ? { origin: f5 } : {}), type: f6, loc: f7 }; + const f4 = r.tag(2, "IrFunction.body[].arr.partialView") === 0 ? undefined : (r.bool() as NonNullable["partialView"]>); + const f5 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str(); + const f6 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num(); + const f7 = decIrType(r); + const f8 = decSrcLoc(r); + const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { partialView: f4 } : {}), ...(f5 !== undefined ? { inferredSite: f5 } : {}), ...(f6 !== undefined ? { origin: f6 } : {}), type: f7, loc: f8 }; return o; } case 26: { From 902e8b4396a7927620b6d5a1150844cc61ff253c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 22:17:47 -0500 Subject: [PATCH 186/193] Ship ICU data without the categories no Intl form reads The runtime pack shipped ICU's full icudt78l.dat (33.1 MB), and every Intl executable gets a copy beside it. scr_intl.c answers its Intl forms with ICU's break iterators, number and date formatters, calendars and locale IDs, which never read the charset converters or the collation, language, region, time zone name, unit, spell-out and transliterator trees: 21.5 MB of the file. - The pack build writes icudt78l.dat without those items (ICU_DATA_DROPPED in icu-inputs.mjs). filterIcuData rewrites the data file's table of contents to the remaining items, and the result is checked against a pinned sha256 and size (11,653,984 B). - tests/harness/icu-data-filter.test.ts runs every @requires-icu corpus program under 47 locales and 3 time zones with the pack's data, with the full data and in Node, and fails when the outputs differ, so an Intl form that reads a dropped category fails there. - intl-word-segmenter-dictionaries.ts segments Chinese, Japanese, Thai, Lao, Khmer and Burmese text, which ICU breaks with the dictionaries in brkitr/. --- packages/compiler/src/backend/linker.ts | 2 +- .../compiler/src/backend/runtime-pack-core.ts | 4 +- .../src/backend/runtime-pack-native.ts | 2 +- .../compiler/src/backend/vendor-inputs.ts | 3 +- .../intl-word-segmenter-dictionaries.ts.json | 6 + .../runtime-pack-common/scripts/build.mjs | 7 +- .../runtime-pack-common/scripts/icu-data.mjs | 112 +++++++++- .../scripts/icu-inputs.mjs | 26 +++ .../runtime-pack-common/test/icu-data.test.ts | 80 +++++++ packages/runtime/vendor/README.md | 4 +- .../intl-word-segmenter-dictionaries.ts | 25 +++ .../intl-word-segmenter-dictionaries.ts.json | 1 + tests/harness/icu-data-filter.test.ts | 206 ++++++++++++++++++ 13 files changed, 460 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter-dictionaries.ts.json create mode 100644 packages/runtime-pack-common/test/icu-data.test.ts create mode 100644 tests/corpus/intl-word-segmenter-dictionaries.ts create mode 100644 tests/harness/corpus-costs/intl-word-segmenter-dictionaries.ts.json create mode 100644 tests/harness/icu-data-filter.test.ts diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index 8d4976403..66a417582 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -235,7 +235,7 @@ async function objectLinkerDependencyPaths( * file next to the binary is what the runtime looks for first. * * A file already present at the pinned size is left alone: the pack ships - * one data set per file name, and re-copying 31.6 MiB on every build would + * one data set per file name, and re-copying 11.1 MiB on every build would * be pure cost. The copy is atomic (private temporary, then rename) so two * concurrent builds of the same program never observe a partial file. */ export async function installRuntimeDataFiles( diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 41bfb1236..e9a74a7a6 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -75,7 +75,7 @@ export interface RuntimePackManifest { flavors: Partial>; archives: RuntimePackArchive[]; /** Files the linked executable needs at RUN time rather than at link time, - * installed beside it on every build. ICU's data file (icudt78l.dat, 31.6 + * installed beside it on every build. ICU's data file (icudt78l.dat, 11.1 * MiB) is the only one; Intl programs copy it, every other program links * neither ICU nor its data. */ data?: RuntimePackArtifact[]; @@ -492,7 +492,7 @@ export function selectRuntimePackArtifacts( archives: manifest.archives.filter((archive) => evaluateRuntimePredicate(archive.predicate, features), ), - // ICU's data file is 31.6 MiB: only a program that links ICU installs it. + // ICU's data file is 11.1 MiB: only a program that links ICU installs it. data: features.intl === true ? (manifest.data ?? []) : [], systemLibraries: manifest.system_libraries .filter((entry) => evaluateRuntimePredicate(entry.predicate, features)) diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index 850258ebb..26968b396 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -429,7 +429,7 @@ export function stageNativeRuntimeSelection( * own selection (backend/linker.ts); this host writes executables * synchronously and has no link plan, so it installs them here. * - * A file already present at the pinned size is left alone (31.6 MiB of pure + * A file already present at the pinned size is left alone (11.1 MiB of pure * cost otherwise), and the copy is atomic — a private temporary, then a * rename — so two concurrent builds of one program never read a partial * file. */ diff --git a/packages/compiler/src/backend/vendor-inputs.ts b/packages/compiler/src/backend/vendor-inputs.ts index 4e87272d8..b03bfc4c1 100644 --- a/packages/compiler/src/backend/vendor-inputs.ts +++ b/packages/compiler/src/backend/vendor-inputs.ts @@ -10,7 +10,8 @@ export const BROTLI_VERSION = "1.2.0"; * (`process.versions.icu` there), from the `release-78.3` tag. ICU is not * vendored: the build fetches the release's own source tarball and data zip * from unicode-org/icu's GitHub release, pinning each by version and sha256, - * and caches the download. Its data file icudt78l.dat (33,107,232 B) ships + * and caches the download. The runtime packs ship its data file icudt78l.dat + * (33,107,232 B) without the categories no Intl form reads (11,653,984 B), * beside the executable; see packages/runtime-pack-common/scripts/ * icu-data.mjs. */ export const ICU_VERSION = "78.3"; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter-dictionaries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter-dictionaries.ts.json new file mode 100644 index 000000000..31197f414 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter-dictionaries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-word-segmenter-dictionaries.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index c4191fec6..c3cf2b7b0 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -20,7 +20,7 @@ import { installRuntimePack, withBuildLock } from "./build-state.mjs"; import { shareIdenticalArtifacts } from "./share-artifacts.mjs"; import { parallelMap } from "./parallel-map.mjs"; import { zlibInputs } from "./zlib-inputs.mjs"; -import { ICU_DATA_FILE, ensureIcuData, ensureIcuSources, icuSourceList } from "./icu-data.mjs"; +import { ICU_DATA_FILE, ensurePackIcuData, ensureIcuSources, icuSourceList } from "./icu-data.mjs"; const run = promisify(execFile); @@ -668,11 +668,12 @@ async function build() { // ICU's data is a RUN-time input, not a link input: the executable loads // icudt78l.dat from beside itself (or ICU_DATA). `data` tells the // compiler where the pack keeps it, so every Intl build installs it - // beside the binary it links. + // beside the binary it links. The pack's copy leaves out the categories + // no Intl form reads (ICU_DATA_DROPPED in icu-inputs.mjs). const data = []; if (requestedArchives.has("icu")) { process.stdout.write(`building ${packageManifest.name} icu data\n`); - const source = await ensureIcuData( + const source = await ensurePackIcuData( join(repoRoot, "node_modules", ".cache", "scriptc-icu-data"), ); const destination = join(stagedOutputRoot, "icu", ICU_DATA_FILE); diff --git a/packages/runtime-pack-common/scripts/icu-data.mjs b/packages/runtime-pack-common/scripts/icu-data.mjs index 2d5e14e38..7d1e2c704 100644 --- a/packages/runtime-pack-common/scripts/icu-data.mjs +++ b/packages/runtime-pack-common/scripts/icu-data.mjs @@ -2,8 +2,11 @@ * * Intl answers from ICU, so the pack builds ICU's C library from the release * source tarball and ships its data as a FILE the executable loads at run - * time (31.6 MiB of CLDR patterns, locale metadata, break rules and - * time-zone shapes) — never embedded in the binary. Neither is vendored: + * time — never embedded in the binary. The release's data file is 31.6 MiB; + * the pack's copy keeps the locale data, break rules and dictionaries, + * currency data and time-zone rules the Intl forms read, 11.1 MiB, and + * leaves out the categories in ICU_DATA_DROPPED (filterIcuData below, + * pinned by ICU_PACK_DATA_SHA256). Neither input is vendored: * both come from unicode-org/icu's official GitHub release for the pinned * version, each downloaded once into a cache and reused after that, and each * verified against a pinned sha256 on every use (the source by the tarball's, @@ -11,9 +14,10 @@ * here with both hashes, never later in a build. Offline builds work from a * pre-populated cache. * - * The pack build drops `icudt78l.dat` into artifacts/icu/ and lists it in - * runtime-pack.json's `data`, so the compiler can install it beside every - * executable that links ICU (packages/compiler/src/backend/linker.ts). The + * The pack build writes that copy of `icudt78l.dat` to artifacts/icu/ and + * lists it in runtime-pack.json's `data`, so the compiler can install it + * beside every executable that links ICU (packages/compiler/src/backend/ + * linker.ts). The * data zip is unpacked by the small reader below rather than a dependency: * it holds three stored/deflated members and nothing else. The source * tarball is unpacked by the system `tar`, as the libnode build does. @@ -21,7 +25,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { inflateRawSync, crc32 } from "node:zlib"; import { @@ -31,6 +35,9 @@ import { ICU_DATA_URL, ICU_DATA_ZIP_SHA256, ICU_DATA_ZIP_SIZE, + ICU_DATA_DROPPED, + ICU_PACK_DATA_SHA256, + ICU_PACK_DATA_SIZE, ICU_SOURCE_SHA256, ICU_SOURCE_SIZE, ICU_SOURCE_URL, @@ -180,7 +187,7 @@ export function unzipMember(zip, name) { export async function ensureIcuData(cacheDir) { const zipPath = join(cacheDir, `icu4c-${ICU_DATA_VERSION}-data-bin-l.zip`); const dataPath = join(cacheDir, `${ICU_DATA_VERSION}`, ICU_DATA_FILE); - if (await validFile(dataPath, ICU_DATA_SHA256)) return dataPath; + if (await validFile(dataPath, ICU_DATA_SHA256, ICU_DATA_SIZE)) return dataPath; let zip; try { zip = await readFile(zipPath); @@ -211,11 +218,98 @@ export async function ensureIcuData(cacheDir) { return dataPath; } -async function validFile(path, expectedSha256) { +async function validFile(path, expectedSha256, expectedSize) { try { - if ((await stat(path)).size !== ICU_DATA_SIZE) return false; + if ((await stat(path)).size !== expectedSize) return false; return sha256(await readFile(path)) === expectedSha256; } catch { return false; } } + +/** The items of an ICU common data file (a `.dat`: a header, then a table of + * contents of name and data offsets, sorted by name, then the items): each + * item's name (`icudt78l/coll/de.res`) and its bytes. */ +export function icuDataItems(bytes) { + const headerSize = bytes.readUInt16LE(0); + if (bytes[2] !== 0xda || bytes[3] !== 0x27) throw new Error("not an ICU data file"); + const count = bytes.readUInt32LE(headerSize); + const entries = []; + for (let i = 0; i < count; i++) { + const nameStart = headerSize + bytes.readUInt32LE(headerSize + 4 + i * 8); + const nameEnd = bytes.indexOf(0, nameStart); + entries.push({ + name: bytes.toString("latin1", nameStart, nameEnd), + start: headerSize + bytes.readUInt32LE(headerSize + 8 + i * 8), + }); + } + return entries.map((entry, i) => ({ + name: entry.name, + bytes: bytes.subarray( + entry.start, + i + 1 < entries.length ? entries[i + 1].start : bytes.length, + ), + })); +} + +/** Whether a data item falls under one of the ICU_DATA_DROPPED patterns. */ +export function icuDataItemDropped(name, dropped = ICU_DATA_DROPPED) { + const path = name.slice(name.indexOf("/") + 1); + return dropped.some((pattern) => + pattern.startsWith("*.") + ? !path.includes("/") && path.endsWith(pattern.slice(1)) + : path.startsWith(pattern), + ); +} + +/** An ICU common data file holding the items of `bytes` that `keep` accepts, + * in their order, laid out as icupkg writes one: the original header, the + * table of contents, the names, then each item at a 16-byte boundary. ICU + * finds items by a binary search of the sorted table, so a subset of a valid + * file in its order is a valid file. */ +export function filterIcuData(bytes, keep) { + const headerSize = bytes.readUInt16LE(0); + const items = icuDataItems(bytes).filter((item) => keep(item.name)); + const names = items.map((item) => Buffer.from(`${item.name}\0`, "latin1")); + const tocSize = 4 + items.length * 8; + const align = (n) => (n + 15) & ~15; + let nameOffset = tocSize; + let dataOffset = align(tocSize + names.reduce((sum, name) => sum + name.length, 0)); + const layout = items.map((item, i) => { + const entry = { nameOffset, dataOffset }; + nameOffset += names[i].length; + dataOffset = align(dataOffset + item.bytes.length); + return entry; + }); + const out = Buffer.alloc(headerSize + dataOffset); + bytes.copy(out, 0, 0, headerSize); + out.writeUInt32LE(items.length, headerSize); + items.forEach((item, i) => { + out.writeUInt32LE(layout[i].nameOffset, headerSize + 4 + i * 8); + out.writeUInt32LE(layout[i].dataOffset, headerSize + 8 + i * 8); + names[i].copy(out, headerSize + layout[i].nameOffset); + item.bytes.copy(out, headerSize + layout[i].dataOffset); + }); + return out; +} + +/** The runtime pack's data file: the pinned icudt78l.dat without the + * ICU_DATA_DROPPED items, written into `cacheDir` once and verified against + * its own pin on every call. Returns its path. */ +export async function ensurePackIcuData(cacheDir) { + const dataPath = join(cacheDir, `${ICU_DATA_VERSION}-pack`, ICU_DATA_FILE); + if (await validFile(dataPath, ICU_PACK_DATA_SHA256, ICU_PACK_DATA_SIZE)) return dataPath; + const full = await readFile(await ensureIcuData(cacheDir)); + const bytes = filterIcuData(full, (name) => !icuDataItemDropped(name)); + if (bytes.length !== ICU_PACK_DATA_SIZE || sha256(bytes) !== ICU_PACK_DATA_SHA256) + throw new Error( + `filtered ICU data is ${bytes.length} bytes with sha256 ${sha256(bytes)}, ` + + `expected ${ICU_PACK_DATA_SIZE} bytes with sha256 ${ICU_PACK_DATA_SHA256} ` + + "(update ICU_PACK_DATA_SHA256 and ICU_PACK_DATA_SIZE in icu-inputs.mjs with ICU_DATA_DROPPED)", + ); + await mkdir(dirname(dataPath), { recursive: true }); + const temporary = `${dataPath}.${process.pid}.tmp`; + await writeFile(temporary, bytes); + await rename(temporary, dataPath); + return dataPath; +} diff --git a/packages/runtime-pack-common/scripts/icu-inputs.mjs b/packages/runtime-pack-common/scripts/icu-inputs.mjs index 0aef3ba37..7dfe8379d 100644 --- a/packages/runtime-pack-common/scripts/icu-inputs.mjs +++ b/packages/runtime-pack-common/scripts/icu-inputs.mjs @@ -22,3 +22,29 @@ export const ICU_DATA_ZIP_SIZE = 12_836_659; export const ICU_DATA_FILE = "icudt78l.dat"; export const ICU_DATA_SHA256 = "d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b"; export const ICU_DATA_SIZE = 33_107_232; + +/** The data categories the runtime pack's copy of icudt78l.dat leaves out: + * item-name prefixes under `icudt78l/` (a trailing `/` names a tree) and + * `*.cnv` for the charset converters. scr_intl.c answers its Intl forms with + * ICU's break iterators, number and date formatters, calendars and locale + * IDs, which read the locale data, `brkitr/` and `curr/` and never these: + * converters, collation, display names of languages, regions and time + * zones, unit names, spell-out rules and transliterators. An Intl form that + * needs one of them removes it from this list and updates the two pins + * below in the same change. tests/harness/icu-data-filter.test.ts runs every + * `@requires-icu` corpus program with the pack's file and with the full one + * and fails when their outputs differ. */ +export const ICU_DATA_DROPPED = [ + "*.cnv", + "coll/", + "lang/", + "rbnf/", + "region/", + "translit/", + "unit/", + "zone/", +]; +/** icudt78l.dat without the ICU_DATA_DROPPED items (icu-data.mjs, filterIcuData). */ +export const ICU_PACK_DATA_SHA256 = + "513a72821c8ee68c7e8a24c346cadc2f8de22efaa19fb68c14957f313e74f35b"; +export const ICU_PACK_DATA_SIZE = 11_653_984; diff --git a/packages/runtime-pack-common/test/icu-data.test.ts b/packages/runtime-pack-common/test/icu-data.test.ts new file mode 100644 index 000000000..43c524f31 --- /dev/null +++ b/packages/runtime-pack-common/test/icu-data.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "vitest"; +import { filterIcuData, icuDataItemDropped, icuDataItems } from "../scripts/icu-data.mjs"; + +/** A common data file in ICU's layout: a 32-byte header with the 0xda27 + * magic, the table of contents, the names, and each item at a 16-byte + * boundary (padded with 0xaa, as ICU's tools pad). */ +function dataFile(items: [string, string][]): Buffer { + const header = Buffer.alloc(32); + header.writeUInt16LE(32, 0); + header[2] = 0xda; + header[3] = 0x27; + const names = Buffer.concat(items.map(([name]) => Buffer.from(`${name}\0`, "latin1"))); + const tocSize = 4 + items.length * 8; + const toc = Buffer.alloc(tocSize); + toc.writeUInt32LE(items.length, 0); + let nameOffset = tocSize; + let offset = (tocSize + names.length + 15) & ~15; + const body: Buffer[] = []; + items.forEach(([name, contents], i) => { + toc.writeUInt32LE(nameOffset, 4 + i * 8); + toc.writeUInt32LE(offset, 8 + i * 8); + nameOffset += name.length + 1; + const padded = Buffer.alloc((contents.length + 15) & ~15, 0xaa); + padded.write(contents, "latin1"); + body.push(padded); + offset += padded.length; + }); + const lead = Buffer.alloc(((tocSize + names.length + 15) & ~15) - tocSize - names.length, 0xaa); + return Buffer.concat([header, toc, names, lead, ...body]); +} + +const items: [string, string][] = [ + ["icudt78l/brkitr/word.brk", "word rules"], + ["icudt78l/coll/de.res", "collation"], + ["icudt78l/curr/de.res", "currencies"], + ["icudt78l/de.res", "locale"], + ["icudt78l/ibm-1047_P100-1995.cnv", "converter"], + ["icudt78l/zone/de.res", "zone names"], + ["icudt78l/zoneinfo64.res", "time zones"], +]; + +test("the dropped categories match trees and root-level converters only", () => { + const dropped = items.map(([name]) => icuDataItemDropped(name)); + expect(dropped).toEqual([false, true, false, false, true, true, false]); + expect(icuDataItemDropped("icudt78l/brkitr/x.cnv")).toBe(false); +}); + +test("a filtered file keeps the accepted items' names, order and bytes", () => { + const file = dataFile(items); + const filtered = filterIcuData(file, (name) => !icuDataItemDropped(name)); + expect(filtered.subarray(0, 32).equals(file.subarray(0, 32))).toBe(true); + const kept = icuDataItems(filtered); + expect(kept.map((item) => item.name)).toEqual([ + "icudt78l/brkitr/word.brk", + "icudt78l/curr/de.res", + "icudt78l/de.res", + "icudt78l/zoneinfo64.res", + ]); + for (const item of kept) { + const [, contents] = items.find(([name]) => name === item.name)!; + expect(item.bytes.subarray(0, contents.length).toString("latin1")).toBe(contents); + } + for (const [i, item] of kept.entries()) + expect(filtered.readUInt32LE(32 + 8 + i * 8) % 16, item.name).toBe(0); +}); + +test("keeping every item reproduces each item", () => { + const file = dataFile(items); + const same = icuDataItems(filterIcuData(file, () => true)); + const original = icuDataItems(file); + expect(same.length).toBe(original.length); + for (const [i, item] of same.entries()) { + expect(item.name).toBe(original[i]!.name); + expect(item.bytes.subarray(0, 10).equals(original[i]!.bytes.subarray(0, 10))).toBe(true); + } +}); + +test("a file without ICU's magic is refused", () => { + expect(() => icuDataItems(Buffer.alloc(64))).toThrow("not an ICU data file"); +}); diff --git a/packages/runtime/vendor/README.md b/packages/runtime/vendor/README.md index e032f1a53..228ea5233 100644 --- a/packages/runtime/vendor/README.md +++ b/packages/runtime/vendor/README.md @@ -91,7 +91,9 @@ The locale/CLDR engine behind the Intl forms only it can answer (see packages/ru ICU is not vendored: 970 source files plus 33 MiB of compiled CLDR tables are a download, not a reviewable tree in this repository. Both build inputs come from unicode-org/icu's official GitHub release for the pinned version, and both are pinned by version and sha256 in packages/compiler/src/backend/vendor-inputs.ts (mirrored in packages/runtime-pack-common/scripts/icu-inputs.mjs for the runtime packs, with a unit test that keeps the two identical): - `icu4c-78.3-sources.tgz` (27,977,255 B, sha256 `3a2e7a47604ba702f345878308e6fefeca612ee895cf4a5f222e7955fabfe0c0`), the release's own source tarball: `common/`, `i18n/` and `stubdata/` (the empty data-symbol stub ICU needs when its data ships outside the archive), plus LICENSE. `source/common/sources.txt` and `source/i18n/sources.txt` are the *lists of record*: a build compiles exactly the units ICU's own autoconf build compiles, so `icuSourceList` (vendor-inputs.ts for the source-build path, packages/runtime-pack-common/scripts/icu-data.mjs for the runtime packs) reads them rather than re-deriving the set. -- `icu4c-78.3-data-bin-l.zip` (12,836,659 B, sha256 `982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c`), whose `icudt78l.dat` (33,107,232 B, sha256 `d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b`) is not compiled: it is shipped as `artifacts/icu/icudt78l.dat` and installed beside every executable that links ICU, where the runtime looks first (`$ORIGIN`-style: the executable's own directory), then `ICU_DATA`. +- `icu4c-78.3-data-bin-l.zip` (12,836,659 B, sha256 `982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c`), whose `icudt78l.dat` (33,107,232 B, sha256 `d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b`) is not compiled. The pack build writes a copy without the data categories no Intl form reads, ships it as `artifacts/icu/icudt78l.dat` and installs it beside every executable that links ICU, where the runtime looks first (`$ORIGIN`-style: the executable's own directory), then `ICU_DATA`. + +The pack's copy of `icudt78l.dat` (11,653,984 B, sha256 `513a72821c8ee68c7e8a24c346cadc2f8de22efaa19fb68c14957f313e74f35b`) leaves out the items `ICU_DATA_DROPPED` in icu-inputs.mjs names: the charset converters (`*.cnv`) and the `coll/`, `lang/`, `rbnf/`, `region/`, `translit/`, `unit/` and `zone/` trees, 21.5 MB that scr_intl.c's break iterators, number and date formatters, calendars and locale IDs never read. `filterIcuData` in icu-data.mjs rewrites the data file's table of contents to the remaining items, and the build checks the result against the pinned sha256 and size. tests/harness/icu-data-filter.test.ts runs every `@requires-icu` corpus program under 47 locales and 3 time zones with the pack's data, with the full data and in Node, and fails when the outputs differ. An Intl form that reads a dropped category (time zone names, display names, collation, unit names) removes it from `ICU_DATA_DROPPED` and updates the two pins in the same change. Each download is cached (the runtime-pack build under `node_modules/.cache/scriptc-icu-*`, the source-build path under the per-user build cache's `vendor/`), verified against its pinned sha256 on every use, and unpacked by the small zip reader in icu-data.mjs (the data) or the system `tar` (the sources). A hash mismatch fails the build with both hashes; an offline build works from a pre-populated cache. To update, bump `ICU_VERSION` with the new release's two assets' sizes and sha256 in vendor-inputs.ts and icu-inputs.mjs. diff --git a/tests/corpus/intl-word-segmenter-dictionaries.ts b/tests/corpus/intl-word-segmenter-dictionaries.ts new file mode 100644 index 000000000..cbb5034c5 --- /dev/null +++ b/tests/corpus/intl-word-segmenter-dictionaries.ts @@ -0,0 +1,25 @@ +// @requires-icu +// @env: LC_ALL=en_US.UTF-8 +// Word segmentation of scripts written without spaces: Chinese, Japanese, +// Thai, Lao, Khmer and Burmese. ICU breaks them with the dictionaries in its +// data file (brkitr/*.dict), as Node does; each line lists a text's word-like +// segments and the boundaries of all its segments. +const texts = [ + "我们今天去北京大学参观博物馆。", + "東京都の天気は晴れです。日本語の文章を単語に分けます。", + "ภาษาไทยเป็นภาษาที่ไม่มีการเว้นวรรคระหว่างคำ", + "ພາສາລາວເປັນພາສາທີ່ສວຍງາມ", + "ភាសាខ្មែរគឺជាភាសាផ្លូវការរបស់ប្រទេសកម្ពុជា", + "မြန်မာဘာသာစကားသည်မြန်မာနိုင်ငံ၏ရုံးသုံးဘာသာဖြစ်သည်", + "한국어는 띄어쓰기를 합니다.", +]; +const segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); +for (const text of texts) { + const words: string[] = []; + const boundaries: number[] = []; + for (const { segment, index, isWordLike } of segmenter.segment(text)) { + boundaries.push(index); + if (isWordLike === true) words.push(segment); + } + console.log(words.join("|"), boundaries.join(",")); +} diff --git a/tests/harness/corpus-costs/intl-word-segmenter-dictionaries.ts.json b/tests/harness/corpus-costs/intl-word-segmenter-dictionaries.ts.json new file mode 100644 index 000000000..b18912827 --- /dev/null +++ b/tests/harness/corpus-costs/intl-word-segmenter-dictionaries.ts.json @@ -0,0 +1 @@ +{ "plain": 8690, "san": 67970 } diff --git a/tests/harness/icu-data-filter.test.ts b/tests/harness/icu-data-filter.test.ts new file mode 100644 index 000000000..2e3788124 --- /dev/null +++ b/tests/harness/icu-data-filter.test.ts @@ -0,0 +1,206 @@ +/* The runtime pack ships icudt78l.dat without the categories no Intl form + * reads (ICU_DATA_DROPPED in packages/runtime-pack-common/scripts/ + * icu-inputs.mjs). Every `@requires-icu` corpus program runs under a sweep + * of locales and time zones three ways: with the pack's data beside it, with + * ICU's full data file beside it, and in Node. The three outputs must be + * equal. A later Intl form that reads a dropped category gets ICU's root + * fallback or a missing-data error from the pack's file and fails here; + * that change removes the category from ICU_DATA_DROPPED and updates the + * pinned sha256 and size beside it. */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, globSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { availableParallelism } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { afterAll, describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const corpusDir = join(repoRoot, "tests/corpus"); +const workDir = join(repoRoot, "node_modules/.cache/scriptc-tests/icu-data-filter"); + +const icuData = (await import( + pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/icu-data.mjs")).href +)) as { + ensureIcuData(cacheDir: string): Promise; + filterIcuData(bytes: Buffer, keep: (name: string) => boolean): Buffer; + icuDataItemDropped(name: string): boolean; +}; +const icuInputs = (await import( + pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/icu-inputs.mjs")).href +)) as { ICU_DATA_DROPPED: string[]; ICU_PACK_DATA_SHA256: string }; + +const LOCALES = [ + "en_US.UTF-8", + "de_DE.UTF-8", + "fr_FR.UTF-8", + "fr_CA.UTF-8", + "es_MX.UTF-8", + "pt_BR.UTF-8", + "it_IT.UTF-8", + "nl_NL.UTF-8", + "sv_SE.UTF-8", + "fi_FI.UTF-8", + "pl_PL.UTF-8", + "ru_RU.UTF-8", + "uk_UA.UTF-8", + "tr_TR.UTF-8", + "el_GR.UTF-8", + "he_IL.UTF-8", + "ar_EG.UTF-8", + "ar_SA.UTF-8", + "fa_IR.UTF-8", + "ur_PK.UTF-8", + "hi_IN.UTF-8", + "bn_BD.UTF-8", + "mr_IN.UTF-8", + "ne_NP.UTF-8", + "ta_IN.UTF-8", + "th_TH.UTF-8", + "lo_LA.UTF-8", + "km_KH.UTF-8", + "my_MM.UTF-8", + "ja_JP.UTF-8", + "ko_KR.UTF-8", + "zh_CN.UTF-8", + "zh_TW.UTF-8", + "zh_HK.UTF-8", + "vi_VN.UTF-8", + "id_ID.UTF-8", + "am_ET.UTF-8", + "sw_KE.UTF-8", + "ps_AF.UTF-8", + "sr_RS.UTF-8", + "sr_RS@latin", + "ca_ES@valencia", + "de_CH.UTF-8", + "en_IN.UTF-8", + "en_GB.UTF-8", + "C.UTF-8", + "POSIX", +]; +const TIME_ZONES = ["UTC", "Asia/Kolkata", "America/New_York"]; + +const programs = [...globSync(join(corpusDir, "*.ts")), ...globSync(join(corpusDir, "*/main.ts"))] + .filter((file) => + readFileSync(file, "utf8") + .split("\n", 2) + .some((line) => /^\/\/ @requires-icu\s*$/.test(line)), + ) + .sort(); + +/** The program's `// @env:` variables other than TZ and LC_ALL, which the + * sweep sets. */ +function programEnv(file: string): Record { + const env: Record = {}; + for (const line of readFileSync(file, "utf8").split("\n", 2)) { + const m = /^\/\/ @env:\s*(.+?)\s*$/.exec(line); + if (!m) continue; + for (const pair of m[1]!.split(",")) { + const eq = pair.indexOf("="); + if (eq > 0) env[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + } + delete env["TZ"]; + delete env["LC_ALL"]; + return env; +} + +async function run(cmd: string, args: string[], env: Record): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { + env: { ...process.env, LANG: "", LC_MESSAGES: "", ...env }, + maxBuffer: 16 << 20, + }); + return `${stdout}\n--- stderr\n${stderr}`; + } catch (error) { + const e = error as { stdout?: string; stderr?: string; code?: number }; + return `${e.stdout ?? ""}\n--- stderr\n${e.stderr ?? ""}\n--- exit ${e.code}`; + } +} + +/** Runs `tasks` with at most `limit` in flight. */ +async function pool(tasks: (() => Promise)[], limit: number): Promise { + const results: T[] = Array.from({ length: tasks.length }); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, tasks.length) }, async () => { + while (next < tasks.length) { + const i = next++; + results[i] = await tasks[i]!(); + } + }), + ); + return results; +} + +const skip = process.platform !== "linux" || process.arch !== "x64"; + +describe.skipIf(skip)("the runtime pack's filtered ICU data", () => { + afterAll(() => rmSync(workDir, { recursive: true, force: true })); + + test("is the pinned full data file without the dropped categories", async () => { + mkdirSync(workDir, { recursive: true }); + const full = readFileSync( + await icuData.ensureIcuData(join(repoRoot, "node_modules/.cache/scriptc-icu-data")), + ); + const filtered = icuData.filterIcuData(full, (name) => !icuData.icuDataItemDropped(name)); + expect(createHash("sha256").update(filtered).digest("hex")).toBe( + icuInputs.ICU_PACK_DATA_SHA256, + ); + expect(icuInputs.ICU_DATA_DROPPED.length).toBeGreaterThan(0); + }, 120_000); + + test.for(programs.map((file) => [file.slice(corpusDir.length + 1), file] as const))( + "%s gives the same output with the pack's data, the full data and Node", + async ([rel, file]) => { + const out = join(workDir, rel.replace(/[/.]/g, "_")); + const result = await compile(file, { + outPath: join(out, "filtered", "program"), + outDir: join(out, "filtered"), + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const installed = readFileSync(join(dirname(result.binaryPath), "icudt78l.dat")); + expect(createHash("sha256").update(installed).digest("hex")).toBe( + icuInputs.ICU_PACK_DATA_SHA256, + ); + // The same executable with ICU's full data file beside it, where the + // runtime looks first. + const fullBinary = join(out, "full", basename(result.binaryPath)); + mkdirSync(dirname(fullBinary), { recursive: true }); + copyFileSync(result.binaryPath, fullBinary); + copyFileSync( + await icuData.ensureIcuData(join(repoRoot, "node_modules/.cache/scriptc-icu-data")), + join(dirname(fullBinary), "icudt78l.dat"), + ); + const env = programEnv(file); + const cases = TIME_ZONES.flatMap((tz) => + LOCALES.map((locale) => ({ ...env, TZ: tz, LC_ALL: locale })), + ); + const outputs = await pool( + cases.map((caseEnv) => async () => ({ + env: `TZ=${caseEnv.TZ} LC_ALL=${caseEnv.LC_ALL}`, + filtered: await run(result.binaryPath, [], caseEnv), + full: await run(fullBinary, [], caseEnv), + node: await run(process.execPath, [file], caseEnv), + })), + Math.max(2, availableParallelism() >> 1), + ); + for (const o of outputs) { + expect( + o.filtered, + `${rel} under ${o.env}: the pack's ICU data answers differently from the full data; ` + + `an Intl form reads a category in ICU_DATA_DROPPED (${icuInputs.ICU_DATA_DROPPED.join(", ")})`, + ).toBe(o.full); + expect(o.full, `${rel} under ${o.env}: scriptc with the full ICU data and Node`).toBe( + o.node, + ); + } + }, + 600_000, + ); +}); From d80b34d92dc735392a46afa35545a8a19cfe622d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 22:18:16 -0500 Subject: [PATCH 187/193] Match Node's socket encodings, rejected writes and event loop liveness --- .../limitations/socket-data-encoding.mdx | 2 +- .../generated/node-v24-internal.json | 279 +++++++++++++++--- internal/compatibility/static-support.json | 2 +- .../corpus/net-paused-socket-exit.cjs.json | 6 + .../net-server-socket-peer-close.cjs.json | 6 + .../corpus/net-socket-method-values.cjs.json | 6 + .../net-socket-set-encoding-names.ts.json | 6 + .../corpus/net-socket-write-rejected.cjs.json | 6 + packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_json.c | 5 + packages/runtime/src/scr_net.c | 253 +++++++++++++--- tests/corpus/net-paused-socket-exit.cjs | 24 ++ tests/corpus/net-server-socket-peer-close.cjs | 29 ++ tests/corpus/net-socket-method-values.cjs | 41 +++ tests/corpus/net-socket-set-encoding-names.ts | 46 +++ tests/corpus/net-socket-write-rejected.cjs | 76 +++++ .../net-paused-socket-exit.cjs.json | 1 + .../net-server-socket-peer-close.cjs.json | 1 + .../net-socket-method-values.cjs.json | 1 + .../net-socket-set-encoding-names.ts.json | 1 + .../net-socket-write-rejected.cjs.json | 1 + tests/harness/node-parallel-runner.test.ts | 13 +- tests/node-parallel/README.md | 2 +- tests/node-parallel/common/index.cjs | 50 ++++ tests/node-parallel/common/tmpdir.cjs | 36 +++ tests/node-parallel/run.mjs | 7 +- 26 files changed, 816 insertions(+), 85 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-paused-socket-exit.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-server-socket-peer-close.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-method-values.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-set-encoding-names.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-write-rejected.cjs.json create mode 100644 tests/corpus/net-paused-socket-exit.cjs create mode 100644 tests/corpus/net-server-socket-peer-close.cjs create mode 100644 tests/corpus/net-socket-method-values.cjs create mode 100644 tests/corpus/net-socket-set-encoding-names.ts create mode 100644 tests/corpus/net-socket-write-rejected.cjs create mode 100644 tests/harness/corpus-costs/net-paused-socket-exit.cjs.json create mode 100644 tests/harness/corpus-costs/net-server-socket-peer-close.cjs.json create mode 100644 tests/harness/corpus-costs/net-socket-method-values.cjs.json create mode 100644 tests/harness/corpus-costs/net-socket-set-encoding-names.ts.json create mode 100644 tests/harness/corpus-costs/net-socket-write-rejected.cjs.json create mode 100644 tests/node-parallel/common/tmpdir.cjs diff --git a/docs/content/limitations/socket-data-encoding.mdx b/docs/content/limitations/socket-data-encoding.mdx index 4384dd97f..955857607 100644 --- a/docs/content/limitations/socket-data-encoding.mdx +++ b/docs/content/limitations/socket-data-encoding.mdx @@ -1,3 +1,3 @@ -After `socket.setEncoding("utf8")`, an inline `'data'` listener receives strings, as in Node, whatever type its chunk parameter declares. +After `socket.setEncoding()`, an inline `'data'` listener receives strings, as in Node, whatever type its chunk parameter declares. A listener passed by reference, such as a named function whose parameter is typed `Buffer`, keeps its `Buffer` parameter, so a string chunk throws `TypeError: expected Uint8Array at $[0], got string`. Declare that parameter as `string` or `string | Buffer`, or pass an inline function. diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 5e5cc8c4c..961b40bd4 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -54788,6 +54788,11 @@ "tests/corpus/2986-http-static-client-socket-finish.ts", "tests/corpus/2987-http-static-timeout-errors.ts", "tests/corpus/2988-http-static-client-abort.ts", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", "tests/fixtures/server/cases/http-trailers/main.ts", "tests/fixtures/server/cases/http2-connect/main.ts", "tests/fixtures/server/cases/upgrade-both/main.ts" @@ -56336,7 +56341,12 @@ "evidence": "compiler-dedicated:server.close", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -56419,7 +56429,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -59448,7 +59463,12 @@ "tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs", "tests/corpus/2672-http-request-response-callback.ts", - "tests/corpus/2689-http-server-timeout-properties.ts" + "tests/corpus/2689-http-server-timeout-properties.ts", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs" ] }, "anchorSource": "chapter" @@ -61706,7 +61726,12 @@ "evidence": "compiler-dedicated:server.close", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -62014,7 +62039,12 @@ "evidence": "compiler-dedicated:server.close", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -63842,7 +63872,12 @@ "tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs", "tests/corpus/2672-http-request-response-callback.ts", - "tests/corpus/2689-http-server-timeout-properties.ts" + "tests/corpus/2689-http-server-timeout-properties.ts", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs" ] }, "anchorSource": "chapter" @@ -63938,7 +63973,12 @@ "evidence": "compiler-dedicated:server.close", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -64034,7 +64074,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72029,6 +72074,11 @@ "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts", "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", "tests/fixtures/node-types/net-blocklist.ts" ] }, @@ -72637,7 +72687,12 @@ "evidence": "compiler-dedicated:server.address", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72659,7 +72714,12 @@ "evidence": "compiler-dedicated:server.close", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72717,7 +72777,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72739,7 +72804,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72761,7 +72831,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72783,7 +72858,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -72805,7 +72885,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73148,7 +73233,12 @@ "evidence": "compiler-dedicated:socket.destroy", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73188,7 +73278,12 @@ "evidence": "compiler-dedicated:socket.end", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73210,7 +73305,12 @@ "evidence": "compiler-dedicated:socket.pause", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73271,7 +73371,12 @@ "evidence": "compiler-dedicated:socket.resume", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73293,7 +73398,12 @@ "evidence": "compiler-dedicated:socket.setEncoding", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73387,7 +73497,12 @@ "evidence": "compiler-dedicated:socket.setTimeout", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -73466,7 +73581,12 @@ "evidence": "compiler-dedicated:socket.write", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74110,7 +74230,12 @@ "evidence": "compiler-dedicated:net.connect", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74132,7 +74257,12 @@ "evidence": "compiler-dedicated:net.connect", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74154,7 +74284,12 @@ "evidence": "compiler-dedicated:net.connect", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74176,7 +74311,12 @@ "evidence": "compiler-dedicated:net.connect", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74198,7 +74338,12 @@ "evidence": "compiler-dedicated:net.createConnection", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74220,7 +74365,12 @@ "evidence": "compiler-dedicated:net.createConnection", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74242,7 +74392,12 @@ "evidence": "compiler-dedicated:net.createConnection", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74264,7 +74419,12 @@ "evidence": "compiler-dedicated:net.createConnection", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74286,7 +74446,12 @@ "evidence": "compiler-dedicated:net.createServer", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74344,7 +74509,12 @@ "evidence": "compiler-dedicated:net.getDefaultAutoSelectFamilyAttemptTimeout", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -74366,7 +74536,12 @@ "evidence": "compiler-dedicated:net.setDefaultAutoSelectFamilyAttemptTimeout", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -75823,6 +75998,11 @@ "tests/corpus/5214-perf-entry-inspect.ts", "tests/corpus/5215-perf-timing-validation.js", "tests/corpus/6397-event-loop-metrics.ts", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", "tests/fixtures/node-types/event-loop-metrics.ts" ] }, @@ -76126,7 +76306,12 @@ "evidence": "compiler-dedicated:net.connect", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -96067,6 +96252,11 @@ "tests/corpus/7440-tls-server-add-context.ts", "tests/corpus/7441-tls-client-error.ts", "tests/corpus/7442-tls-server-contexts.mjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", "tests/fixtures/node-types/tls-client-certificates.ts", "tests/fixtures/node-types/tls-peer-certificates.ts", "tests/fixtures/node-types/tls-server-contexts.ts", @@ -96471,7 +96661,12 @@ "evidence": "compiler-dedicated:server.address", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -96498,7 +96693,12 @@ "evidence": "compiler-dedicated:server.close", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" @@ -96548,7 +96748,12 @@ "evidence": "compiler-dedicated:server.listen", "tests": [ "tests/corpus/2500-net-autosel-timeout.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/net-socket-set-encoding-names.ts", + "tests/corpus/net-socket-write-rejected.cjs", + "tests/corpus/net-socket-method-values.cjs", + "tests/corpus/net-paused-socket-exit.cjs", + "tests/corpus/net-server-socket-peer-close.cjs" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 3918a8941..2d74d4de5 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -479,7 +479,7 @@ { "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] }, { "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] }, { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs", "tests/corpus/writable-error-callbacks.ts"] }, - { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs"] }, + { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs"] }, { "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] }, { "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, { "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-paused-socket-exit.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-paused-socket-exit.cjs.json new file mode 100644 index 000000000..62eabb179 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-paused-socket-exit.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-paused-socket-exit.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-server-socket-peer-close.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-server-socket-peer-close.cjs.json new file mode 100644 index 000000000..27d486e24 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-server-socket-peer-close.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-server-socket-peer-close.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-method-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-method-values.cjs.json new file mode 100644 index 000000000..6b758ed60 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-method-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-socket-method-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-set-encoding-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-set-encoding-names.ts.json new file mode 100644 index 000000000..1add896ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-set-encoding-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-socket-set-encoding-names.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-write-rejected.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-write-rejected.cjs.json new file mode 100644 index 000000000..3605e422c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-write-rejected.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-socket-write-rejected.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2bc1d9a31..b1689bd69 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -2304,6 +2304,7 @@ ScrDyn *scr_gen_receiver(void); * firing site opens/closes it around a 'data' pass; scr_dyn_new_chunk * answers a Buffer-flavored bytes box, or a string inside the window. */ void scr_dyn_chunk_enc(bool utf8); +void scr_dyn_chunk_text(ScrStr *text); /* a decoded string instead; borrowed, NULL closes */ ScrDyn *scr_dyn_new_chunk(const ScrBytes *b); /* The JSON text of a dyn value: the JSON.stringify text (+1), diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 090bfbfc8..4c42d054b 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -6430,10 +6430,15 @@ ScrDyn *scr_dyn_bytes_key_get(const ScrDyn *value, const ScrStr *key) { * bodies — the overwhelming test shape — are exact. */ static SCR_TL bool scr_dyn_chunk_utf8; void scr_dyn_chunk_enc(bool utf8) { scr_dyn_chunk_utf8 = utf8; } +/* A firing site that decodes with a StringDecoder (net sockets) names + * the decoded string instead (borrowed; NULL closes the window). */ +static SCR_TL ScrStr *scr_dyn_chunk_str; +void scr_dyn_chunk_text(ScrStr *text) { scr_dyn_chunk_str = text; } /* One 'data' payload as the dyn value the current window dictates: * a Buffer-flavored bytes box, or a string inside a setEncoding window. */ ScrDyn *scr_dyn_new_chunk(const ScrBytes *b) { + if (scr_dyn_chunk_str != NULL) return scr_dyn_new_str(scr_dyn_chunk_str); if (scr_dyn_chunk_utf8) { ScrStr *s = scr_bytes_string(b, 0, b->len); ScrDyn *d = scr_dyn_new_str(s); diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 9347bdc9b..2c9793d0b 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -506,7 +506,12 @@ struct ScrNetSocket { bool unrefd; /* socket.unref(): does not keep the loop alive */ bool connecting; bool rd_eof; /* peer FIN seen (or read half abandoned) */ - bool enc_utf8; /* setEncoding('utf8'): 'data' delivers strings */ + bool fin_seen; /* peer FIN seen: 'end' fired (Node's writeAfterFIN state) */ + /* setEncoding(enc): the canonical encoding (+1, NULL without one) and the + * StringDecoder's packed partial sequence (scr_strdec_*). 'data' then + * delivers the decoded strings. */ + ScrStr *encoding; + double dec_pending; bool wr_ending; /* end() called: FIN when the buffer drains */ bool wr_done; /* FIN sent (or write half dead) */ bool had_error; @@ -604,6 +609,10 @@ struct ScrNetSocket { * finish_ls waits for the FIN (wr_done); wcb_ls waits for the write * buffer to drain (this surface's flush moment). */ ScrNetLs finish_ls, wcb_ls; + /* The error of the last user write when the socket's state rejected it + * (static text, NULL after an accepted write): that write's callback + * receives it on the next tick. */ + const char *write_err_code, *write_err_msg; bool finish_pending; /* wr_done seen: fire finish_ls at the next sweep */ ScrNetLs ready_ls; /* 'ready': right after the client's 'connect' listeners */ size_t bytes_read; /* socket.bytesRead: every byte delivered from the peer */ @@ -777,6 +786,7 @@ void scr_net_sock_release(ScrNetSocket *s) { scr_als_ctx_release(s->als); scr_als_ctx_release(s->end_ctx); scr_str_release(s->pending_err); + scr_str_release(s->encoding); scr_str_release(s->remote_cache); for (size_t i = 0; i < s->dial_n; i++) scr_str_release(s->dial_ips[i]); free(s->dial_ips); @@ -1325,11 +1335,45 @@ static bool scr_net_sock_write_open(ScrNetSocket *w) { * is set. A write the kernel takes at once answers true whatever its * size. A write to an ended or closed socket answers false, and so does * one whose send fails and closes the socket. */ +/* A user write the socket's state rejects, as Node's Socket.write and + * Writable.write answer it: after the peer's FIN (Node's writeAfterFIN, + * allowHalfOpen false) EPIPE "This socket has been ended by the other + * party"; after end() ERR_STREAM_WRITE_AFTER_END; after destroy() + * ERR_STREAM_DESTROYED. The write's callback receives the error on the + * next tick. The first two destroy a socket that is not destroyed yet with + * the error, so 'error' and 'close' follow. */ +static void scr_net_sock_write_rejected(ScrNetSocket *w) { + bool destroy = true; + if (w->fin_seen) { + w->write_err_code = "EPIPE"; + w->write_err_msg = "This socket has been ended by the other party"; + } else if (w->wr_ending) { + w->write_err_code = "ERR_STREAM_WRITE_AFTER_END"; + w->write_err_msg = "write after end"; + } else { + w->write_err_code = "ERR_STREAM_DESTROYED"; + w->write_err_msg = "Cannot call write after a stream was destroyed"; + destroy = false; + } + if (!destroy || w->fd < 0 || w->close_emitted) return; + if (!w->pending_err) { + w->pending_err = scr_str_new(w->write_err_msg, strlen(w->write_err_msg)); + w->pending_code = w->write_err_code; + } + w->had_error = true; + scr_net_sock_close_fd(w); +} + static bool scr_net_sock_user_write(ScrNetSocket *s, const char *data, size_t len) { ScrNetSocket *w = s->adopted_by != NULL ? s->adopted_by : s; + w->write_err_code = w->write_err_msg = NULL; bool open = scr_net_sock_write_open(w); + if (!open) { + scr_net_sock_write_rejected(w); + return false; + } scr_net_sock_write_raw(s, data, len); - if (!open || !scr_net_sock_write_open(w)) return false; + if (!scr_net_sock_write_open(w)) return false; bool ok = w->wpending < SCR_NET_WRITE_HWM; if (!ok) w->need_drain = true; return ok; @@ -1491,11 +1535,39 @@ static void scr_net_sock_flush(ScrNetSocket *s) { } } +/* The decoded string of the 'data' emit in progress (borrowed, NULL for a + * Buffer emit): the string-typed listener adapter answers it. */ +static SCR_TL ScrStr *scr_net_chunk_text = NULL; + +/* One 'data' emit to the listeners: the chunk as a Buffer, or `text` (the + * decoded string after setEncoding). Exactly one of chunk and text is + * non-NULL. */ +static void scr_net_sock_fire_data(ScrNetSocket *s, ScrBytes *chunk, ScrStr *text) { + ScrBytes *empty = NULL; + if (chunk == NULL) chunk = empty = scr_bytes_new(SCR_BYTES_U8, 0); + ScrNetL *snap; + size_t nl = scr_net_ls_snapshot(&s->data_ls, &snap); + scr_dyn_this_push(s, SCR_DYNH_NET_SOCKET); + ScrStr *outer = scr_net_chunk_text; + scr_net_chunk_text = text; + scr_dyn_chunk_text(text); + for (size_t i = 0; i < nl; i++) { + if (!scr_exc_pending()) ((ScrNetDataFn)snap[i].fn)(snap[i].cb, chunk); + scr_closure_release(snap[i].cb); + } + scr_dyn_chunk_text(NULL); + scr_net_chunk_text = outer; + scr_dyn_this_pop(); + free(snap); + scr_bytes_release(empty); +} + /* EOF: 'end' fires now (directly — Node's poll-phase emit), then the * write half ends (allowHalfOpen false), and the fd closes once the FIN * is out. 'close' waits for the sweep. */ static void scr_net_sock_eof(ScrNetSocket *s) { s->rd_eof = true; + s->fin_seen = true; scr_net_sock_update_read(s); if (s->native_eof) { s->native_eof(s->native_ctx); @@ -1509,6 +1581,14 @@ static void scr_net_sock_eof(ScrNetSocket *s) { scr_net_sock_maybe_finish_write(dst); } } + if (s->encoding != NULL && s->data_ls.n > 0) { + /* the decoder's end(): a held partial sequence is the last 'data' */ + ScrStr *tail = scr_strdec_end(s->encoding, s->dec_pending); + s->dec_pending = 0; + if (tail->len > 0) scr_net_sock_fire_data(s, NULL, tail); + scr_str_release(tail); + if (scr_exc_pending()) return; + } scr_net_fire0_this(&s->end_ls, s, SCR_DYNH_NET_SOCKET); if (scr_exc_pending()) return; if (!s->wr_ending) { @@ -1536,17 +1616,16 @@ static bool scr_net_sock_deliver(ScrNetSocket *s, const char *buf, size_t n) { ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, buf, n); - ScrNetL *snap; - size_t nl = scr_net_ls_snapshot(&s->data_ls, &snap); - scr_dyn_this_push(s, SCR_DYNH_NET_SOCKET); - scr_dyn_chunk_enc(s->enc_utf8); - for (size_t i = 0; i < nl; i++) { - if (!scr_exc_pending()) ((ScrNetDataFn)snap[i].fn)(snap[i].cb, chunk); - scr_closure_release(snap[i].cb); + if (s->encoding != NULL) { + /* the decoder answers the complete characters; a partial sequence + * waits for the next chunk, and an empty answer emits nothing */ + ScrStr *text = scr_strdec_write(s->encoding, s->dec_pending, chunk); + s->dec_pending = scr_strdec_next(s->encoding, s->dec_pending, chunk); + if (text->len > 0) scr_net_sock_fire_data(s, NULL, text); + scr_str_release(text); + } else { + scr_net_sock_fire_data(s, chunk, NULL); } - scr_dyn_chunk_enc(false); - scr_dyn_this_pop(); - free(snap); scr_bytes_release(chunk); if (scr_exc_pending()) return false; } @@ -2577,6 +2656,9 @@ static void scr_net_server_accept(ScrNetServer *srv) { } scr_dyn_this_pop(); free(snap); + /* With no consumer yet the socket still reads ahead, as Node's + * stream does from the start, so a peer's FIN or reset is seen. */ + if (!srv->native_conn && !scr_exc_pending()) scr_net_sock_update_read(sock); } scr_net_sock_release(sock); /* registry + server-count refs remain */ if (scr_exc_pending()) return; @@ -3625,6 +3707,11 @@ static void scr_net_sock_add_write_cb(ScrNetSocket *s, ScrClosure *cb, void *fn) /* write(chunk, callback): fires when the write buffer drains (this * surface's flush moment), from the sweep. */ void scr_net_sock_on_write_flush(ScrNetSocket *s, ScrClosure *cb /*moves*/) { + ScrNetSocket *w = s->adopted_by != NULL ? s->adopted_by : s; + if (w->write_err_code != NULL) { + scr_next_tick(cb); /* the rejected write's callback */ + return; + } if (s->close_emitted) { scr_closure_release(cb); return; @@ -5029,7 +5116,33 @@ void scr_net_server_close_dyn(ScrNetServer *s, const ScrDyn *listener /*borrowed scr_net_server_close(s, cb); } +/* The rejected write's callback: callback(error) on the next tick. */ +static void scr_net_write_err_fire(ScrClosure *self) { + ScrDyn *fn = (ScrDyn *)scr_box_get_ref(self->caps[0]); + ScrDyn *err = (ScrDyn *)scr_box_get_ref(self->caps[1]); + ScrDyn *args[1] = { err }; + scr_dyn_release(scr_dyn_call(fn, args, 1, "callback")); + scr_dyn_release(err); + scr_dyn_release(fn); +} + void scr_net_sock_on_write_flush_dyn(ScrNetSocket *s, const ScrDyn *listener /*borrowed*/) { + ScrNetSocket *w = s->adopted_by != NULL ? s->adopted_by : s; + if (w->write_err_code != NULL) { + ScrStr *msg = scr_str_new(w->write_err_msg, strlen(w->write_err_msg)); + ScrError *e = scr_error_new(SCR_ERR_ERROR, msg); + scr_str_release(msg); + scr_error_set_code(e, w->write_err_code); + ScrClosure *clo = scr_dyn_listener_closure_fn(listener, (void *)&scr_net_write_err_fire); + ScrClosure *job = scr_closure_new((void *)&scr_net_write_err_fire, 2); + job->caps[0] = scr_box_retain(clo->caps[0]); + job->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(job->caps[1], scr_dyn_from_error(e)); + scr_error_release(e); + scr_closure_release(clo); + scr_next_tick(job); + return; + } if (s->close_emitted) return; scr_net_sock_add_write_cb(s, scr_dyn_listener_closure_fn(listener, (void *)&scr_net_dyn_fire_arg), (void *)&scr_net_dyn_fire_arg); @@ -5270,31 +5383,17 @@ void scr_net_server_emit_connection(ScrNetServer *srv, ScrNetSocket *sock) { * firing server-level events ('request': Node's this === server). */ ScrNetServer *scr_net_sock_server(ScrNetSocket *s) { return s->server; } -/* socket.setEncoding(enc) — the req twin (scr_http_req_set_encoding's - * contract: utf8 flips 'data' to strings, real-but-unsupported encodings - * throw the 'not supported yet' Error, unknown names throw - * ERR_UNKNOWN_ENCODING). */ +/* socket.setEncoding(enc): Node's Readable.setEncoding with a new + * StringDecoder — any Buffer encoding name in any ASCII case; an unknown + * name throws ERR_UNKNOWN_ENCODING. 'data' then delivers the decoded + * strings, and a partial sequence held at EOF is the last 'data'. */ void scr_net_sock_set_encoding(ScrNetSocket *s, ScrStr *enc /*borrowed*/) { - if ((enc->len == 4 && memcmp(enc->data, "utf8", 4) == 0) || - (enc->len == 5 && memcmp(enc->data, "utf-8", 5) == 0)) { - s->enc_utf8 = true; - return; - } - static const char *const known[] = { "ascii", "latin1", "binary", "base64", - "base64url", "hex", "ucs2", "ucs-2", "utf16le", "utf-16le", NULL }; - for (size_t i = 0; known[i]; i++) { - if (enc->len == strlen(known[i]) && memcmp(enc->data, known[i], enc->len) == 0) { - char msg[128]; - int n = snprintf(msg, sizeof msg, "setEncoding('%s') is not supported yet (only 'utf8' here)", - known[i]); - scr_throw_error_msg(SCR_ERR_ERROR, msg, (size_t)n); - return; - } - } - char msg[128]; - int n = snprintf(msg, sizeof msg, "Unknown encoding: %.*s", - (int)(enc->len < 64 ? enc->len : 64), enc->data); - scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)n, "ERR_UNKNOWN_ENCODING"); + /* Node's normalizeEncoding answers utf8 for the empty name */ + ScrStr *canonical = enc->len == 0 ? scr_str_new("utf8", 4) : scr_bytes_normalize_encoding(enc); + if (canonical == NULL) return; /* ERR_UNKNOWN_ENCODING pending */ + scr_str_release(s->encoding); + s->encoding = canonical; + s->dec_pending = 0; } /* pipe(dst): every arrived chunk forwards to dst; src EOF end()s dst @@ -5382,6 +5481,10 @@ void scr_net_data_thunk_dyn(ScrClosure *cb, ScrBytes *chunk) { * a string-typed listener there mistypes its own input, and the decode * is the honest reading of the annotation. */ void scr_net_data_thunk_str(ScrClosure *cb, ScrBytes *chunk) { + if (scr_net_chunk_text != NULL) { + ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, scr_str_retain(scr_net_chunk_text)); + return; + } ScrStr *enc = scr_str_new("utf8", 4); ScrStr *text = scr_bytes_to_str(chunk, enc); scr_str_release(enc); @@ -5669,6 +5772,7 @@ void scr_net_sock_transport_report(ScrNetSocket *s, const char *msg, const char void scr_net_sock_transport_ended(ScrNetSocket *s) { s->rd_eof = true; + s->fin_seen = true; scr_net_fire0_this(&s->end_ls, s, SCR_DYNH_NET_SOCKET); } @@ -6023,12 +6127,24 @@ static void scr_net_sweep(void) { /* ── the loop hooks (scr_async.c) ────────────────────────────────────── */ -/* Loop liveness: a ref'd server or socket, or pending protocol work. */ +/* Whether a socket keeps the loop alive, as libuv's active TCP handle + * does: a ref'd socket that is connecting, reading or writing, or that has + * events still to deliver. A connected socket whose reads are off (pause(), + * or a full read-ahead buffer) and that has nothing to write does not, so a + * program whose only open handle is such a socket exits, as under Node. */ +static bool scr_net_sock_keeps_alive(ScrNetSocket *s) { + if (s->unrefd) return false; + if (s->fd < 0 || s->connecting || s->lookup_wait || scr_net_sock_has_work(s, true)) return true; + return s->read_armed || s->write_armed || s->wlen > s->whead || (s->wr_ending && !s->wr_done); +} + +/* Loop liveness: a ref'd server, an active socket, or pending protocol + * work. */ static bool scr_net_pending(void) { for (ScrNetServer *srv = scr_net_servers; srv; srv = srv->next) if (!srv->unrefd) return true; for (ScrNetSocket *sock = scr_net_socks; sock; sock = sock->next) - if (!sock->unrefd) return true; + if (scr_net_sock_keeps_alive(sock)) return true; return scr_net_proto_pending(); } @@ -6337,8 +6453,9 @@ static ScrDyn *scr_net_dynh_sock_invoke(void *h, ScrDyn *self, const char *metho bool ok = chunk->kind == SCR_DYN_STR ? scr_net_sock_write_str(s, chunk->v.str) : scr_net_sock_write_bytes(s, chunk->v.bytes); if (argc > 1 && args[1]->kind == SCR_DYN_FUNC) { - /* write(chunk, cb): fires when the buffer drains (sweep-deferred) */ - scr_net_sock_on_write_flush(s, scr_dyn_listener_closure0(args[1])); + /* write(chunk, cb): cb(null) when the buffer drains (sweep-deferred), + * or cb(error) for a rejected write */ + scr_net_sock_on_write_flush_dyn(s, args[1]); } return scr_dyn_new_bool(ok); } @@ -6478,6 +6595,60 @@ static ScrDyn *scr_net_dynh_sock_invoke(void *h, ScrDyn *self, const char *metho return NULL; } +/* The Socket members a property read answers as functions (`typeof + * socket.setKeepAlive`, `const write = socket.write`): the names + * scr_net_dynh_sock_invoke dispatches or reports as not supported yet. + * Each name is one function value, as Node's prototype methods are, and + * calls the member on the Socket it is called on. */ +static const char *const scr_net_dynh_sock_methods[] = { + "on", "once", "addListener", "write", "end", "pause", "resume", "setNoDelay", + "setKeepAlive", "destroySoon", "destroy", "setTimeout", "setEncoding", "pipe", "toString", + "ref", "unref", "address", "resetAndDestroy", "connect", "cork", "uncork", "read", "unpipe", + "off", "removeListener", "removeAllListeners", "emit", "prependListener", + "prependOnceListener", "listenerCount", "listeners", +}; +#define SCR_NET_DYNH_SOCK_METHODS (sizeof scr_net_dynh_sock_methods / sizeof *scr_net_dynh_sock_methods) +static SCR_TL ScrDyn *scr_net_dynh_sock_method_fns[SCR_NET_DYNH_SOCK_METHODS]; + +static void scr_net_dynh_sock_methods_release(void) { + for (size_t i = 0; i < SCR_NET_DYNH_SOCK_METHODS; i++) { + scr_dyn_release(scr_net_dynh_sock_method_fns[i]); + scr_net_dynh_sock_method_fns[i] = NULL; + } +} + +static ScrDyn *scr_net_dynh_sock_method_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + const char *method = scr_net_dynh_sock_methods[(size_t)scr_box_get_f64(clo->caps[0])]; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == SCR_DYNH_NET_SOCKET) + result = scr_net_dynh_sock_invoke(self->v.handle.ptr, self, method, args, argc, method); + else + scr_net_dynh_unsupported(method, "called on a value other than a Socket"); + scr_dyn_release(self); + return result; +} + +static ScrDyn *scr_net_dynh_sock_method_value(const char *key) { + for (size_t i = 0; i < SCR_NET_DYNH_SOCK_METHODS; i++) { + if (strcmp(key, scr_net_dynh_sock_methods[i]) != 0) continue; + if (scr_net_dynh_sock_method_fns[i] == NULL) { + bool first = true; + for (size_t j = 0; j < SCR_NET_DYNH_SOCK_METHODS; j++) + if (scr_net_dynh_sock_method_fns[j] != NULL) first = false; + if (first) scr_atexit(scr_net_dynh_sock_methods_release); + ScrClosure *clo = scr_closure_new(NULL, 1); + clo->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(clo->caps[0], (double)i); + scr_net_dynh_sock_method_fns[i] = + scr_dyn_new_func(clo, &scr_net_dynh_sock_method_call, 0, "(...args)", + scr_net_dynh_sock_methods[i]); + } + return scr_dyn_retain(scr_net_dynh_sock_method_fns[i]); + } + return NULL; +} + static ScrDyn *scr_net_dynh_sock_get(void *h, const char *key, size_t key_len) { ScrNetSocket *s = (ScrNetSocket *)h; (void)key_len; @@ -6539,7 +6710,7 @@ static ScrDyn *scr_net_dynh_sock_get(void *h, const char *key, size_t key_len) { } } } - return NULL; + return scr_net_dynh_sock_method_value(key); } static bool scr_net_dynh_sock_set(void *h, const char *key, size_t key_len, const ScrDyn *value) { diff --git a/tests/corpus/net-paused-socket-exit.cjs b/tests/corpus/net-paused-socket-exit.cjs new file mode 100644 index 000000000..6c598f379 --- /dev/null +++ b/tests/corpus/net-paused-socket-exit.cjs @@ -0,0 +1,24 @@ +// A connected socket that is paused and has nothing to write does not +// keep the process alive: with the server and its connection unref'd, +// the process exits before the unref'd timer would fire. +"use strict"; +const net = require("net"); + +net + .createServer((connection) => { + connection.unref(); + }) + .listen(0, "127.0.0.1", function () { + const client = net.connect(this.address().port, "127.0.0.1"); + client.on("connect", () => { + console.log("connected"); + client.pause(); + }); + setTimeout(() => { + console.log("timer fired"); + process.exit(1); + }, 2000).unref(); + }) + .unref(); + +process.on("exit", (code) => console.log("exit", code)); diff --git a/tests/corpus/net-server-socket-peer-close.cjs b/tests/corpus/net-server-socket-peer-close.cjs new file mode 100644 index 000000000..18ccd6ac1 --- /dev/null +++ b/tests/corpus/net-server-socket-peer-close.cjs @@ -0,0 +1,29 @@ +// An accepted socket reads ahead before it has a consumer, as Node's +// stream does, so it sees the peer's FIN or reset: with only 'end' and +// 'close' listeners it emits 'end' and 'close' when the client ends, and +// 'error' (ECONNRESET) and 'close' when the client calls +// resetAndDestroy(). +"use strict"; +const net = require("net"); + +function run(label, finish, done) { + const server = net.createServer((socket) => { + socket.on("end", () => console.log(label, "server socket end")); + socket.on("error", (err) => console.log(label, "server socket error", err.code, err.message)); + socket.on("close", (hadError) => { + console.log(label, "server socket close", hadError); + server.close(done); + }); + }); + server.listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1", () => { + setTimeout(() => finish(client), 20); + }); + }); +} + +run("end:", (client) => client.end(), () => + run("destroy:", (client) => client.destroy(), () => + run("reset:", (client) => client.resetAndDestroy(), () => console.log("done")), + ), +); diff --git a/tests/corpus/net-socket-method-values.cjs b/tests/corpus/net-socket-method-values.cjs new file mode 100644 index 000000000..6e64b4f86 --- /dev/null +++ b/tests/corpus/net-socket-method-values.cjs @@ -0,0 +1,41 @@ +// A socket read through an untyped binding answers its methods as +// functions: typeof reports "function", every socket answers the same +// function for a name, as Node's prototype methods are, and calling the +// function with a socket as `this` runs the method on that socket. +"use strict"; +const net = require("net"); + +const names = ["write", "end", "setKeepAlive", "setNoDelay", "setTimeout", "setEncoding", "pause", "resume", "destroy", "on", "once", "address", "ref", "unref"]; +let first; +const server = net.createServer((socket) => { + console.log(names.map((name) => `${name}:${typeof socket[name]}`).join(" ")); + console.log("missing:", typeof socket.notAMethod); + if (first === undefined) { + first = socket; + return; + } + console.log("same function:", names.every((name) => socket[name] === first[name])); + const write = socket.write; + write.call(socket, "via call\n"); + socket.end.call(socket); + first.end(); +}); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + let open = 2; + const texts = []; + for (let i = 0; i < 2; i++) { + const client = net.connect(port, "127.0.0.1"); + let text = ""; + client.setEncoding("utf8"); + client.on("data", (chunk) => (text += chunk)); + client.on("end", () => { + texts.push(JSON.stringify(text)); + if (--open === 0) { + console.log("clients:", texts.sort().join(" ")); + server.close(); + } + }); + } +}); diff --git a/tests/corpus/net-socket-set-encoding-names.ts b/tests/corpus/net-socket-set-encoding-names.ts new file mode 100644 index 000000000..7e2ec40b6 --- /dev/null +++ b/tests/corpus/net-socket-set-encoding-names.ts @@ -0,0 +1,46 @@ +// socket.setEncoding takes any Buffer encoding name in any ASCII case and +// decodes 'data' with a StringDecoder: a character split across two +// chunks arrives whole, a chunk that completes no character emits no +// 'data', and a partial character held at the end is the last 'data'. +// An unknown name throws ERR_UNKNOWN_ENCODING. +import * as net from "node:net"; + +const server = net.createServer((socket) => { + socket.on("error", () => {}); + // "a", then the two bytes of "é" split across writes, then "b" and the + // first two bytes of "€", which never complete. + socket.write(Buffer.from([0x61, 0xc3])); + setTimeout(() => socket.end(Buffer.from([0xa9, 0x62, 0xe2, 0x82])), 50); +}); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + const encodings = ["UTF8", "Utf-8", "latin1", "BINARY", "hex", "base64", "ascii", "ucs2", "UTF16LE"]; + let left = encodings.length; + const results = new Map(); + for (const encoding of encodings) { + const chunks: string[] = []; + const client = net.connect(port, "127.0.0.1"); + client.setEncoding(encoding as BufferEncoding); + client.on("data", (chunk: string) => chunks.push(`${typeof chunk}:${JSON.stringify(chunk)}`)); + client.on("end", () => { + results.set(encoding, chunks.join(" | ")); + if (--left === 0) { + for (const name of encodings) console.log(name, results.get(name)); + server.close(); + } + }); + } + const probe = net.connect(port, "127.0.0.1"); + probe.on("error", () => {}); + for (const name of ["nope", "utf-16", ""]) { + try { + probe.setEncoding(name as BufferEncoding); + console.log(JSON.stringify(name), "accepted"); + } catch (error) { + const e = error as NodeJS.ErrnoException; + console.log(JSON.stringify(name), e.name, e.code, e.message); + } + } + probe.destroy(); +}); diff --git a/tests/corpus/net-socket-write-rejected.cjs b/tests/corpus/net-socket-write-rejected.cjs new file mode 100644 index 000000000..949681819 --- /dev/null +++ b/tests/corpus/net-socket-write-rejected.cjs @@ -0,0 +1,76 @@ +// A write the socket's state rejects calls its callback with Node's error +// on the next tick: EPIPE after the peer's FIN, ERR_STREAM_WRITE_AFTER_END +// after end(), and ERR_STREAM_DESTROYED after destroy(). The first two +// destroy a socket that is still open, so 'error' and 'close' follow; a +// destroyed socket emits no 'error'. +"use strict"; +const net = require("net"); + +const describe = (err) => (err ? `${err.constructor.name} ${err.code} ${err.message}` : String(err)); +const steps = []; +const log = (line) => steps.push(line); + +function afterFin(done) { + let serverSocket; + const server = net.createServer((socket) => { + serverSocket = socket; + socket.resume(); + socket.on("error", (err) => log(`fin: error ${describe(err)}`)); + }); + server.listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1", () => { + client.on("end", () => { + const ok = serverSocket.write("late", (err) => { + log(`fin: callback ${describe(err)}`); + server.close(done); + }); + log(`fin: write answered ${ok}`); + }); + client.end(); + }); + }); +} + +function afterEnd(done) { + const server = net.createServer((socket) => { + socket.on("error", (err) => log(`end: error ${describe(err)}`)); + socket.on("close", (hadError) => { + log(`end: close ${hadError}`); + server.close(done); + }); + socket.end("bye"); + const ok = socket.write("late", (err) => log(`end: callback ${describe(err)}`)); + log(`end: write answered ${ok}`); + }); + server.listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1"); + client.on("error", () => {}); + client.resume(); + }); +} + +function afterDestroy(done) { + const server = net.createServer((socket) => { + socket.on("error", (err) => log(`destroy: error ${describe(err)}`)); + socket.on("close", (hadError) => { + log(`destroy: close ${hadError}`); + server.close(done); + }); + socket.destroy(); + const ok = socket.write("late", (err) => log(`destroy: callback ${describe(err)}`)); + log(`destroy: write answered ${ok}`); + }); + server.listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1"); + client.on("error", () => {}); + client.resume(); + }); +} + +afterFin(() => + afterEnd(() => + afterDestroy(() => { + for (const step of steps) console.log(step); + }), + ), +); diff --git a/tests/harness/corpus-costs/net-paused-socket-exit.cjs.json b/tests/harness/corpus-costs/net-paused-socket-exit.cjs.json new file mode 100644 index 000000000..030fd6454 --- /dev/null +++ b/tests/harness/corpus-costs/net-paused-socket-exit.cjs.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 3200 } diff --git a/tests/harness/corpus-costs/net-server-socket-peer-close.cjs.json b/tests/harness/corpus-costs/net-server-socket-peer-close.cjs.json new file mode 100644 index 000000000..030fd6454 --- /dev/null +++ b/tests/harness/corpus-costs/net-server-socket-peer-close.cjs.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 3200 } diff --git a/tests/harness/corpus-costs/net-socket-method-values.cjs.json b/tests/harness/corpus-costs/net-socket-method-values.cjs.json new file mode 100644 index 000000000..030fd6454 --- /dev/null +++ b/tests/harness/corpus-costs/net-socket-method-values.cjs.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 3200 } diff --git a/tests/harness/corpus-costs/net-socket-set-encoding-names.ts.json b/tests/harness/corpus-costs/net-socket-set-encoding-names.ts.json new file mode 100644 index 000000000..030fd6454 --- /dev/null +++ b/tests/harness/corpus-costs/net-socket-set-encoding-names.ts.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 3200 } diff --git a/tests/harness/corpus-costs/net-socket-write-rejected.cjs.json b/tests/harness/corpus-costs/net-socket-write-rejected.cjs.json new file mode 100644 index 000000000..030fd6454 --- /dev/null +++ b/tests/harness/corpus-costs/net-socket-write-rejected.cjs.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 3200 } diff --git a/tests/harness/node-parallel-runner.test.ts b/tests/harness/node-parallel-runner.test.ts index a829c4d78..9caebd258 100644 --- a/tests/harness/node-parallel-runner.test.ts +++ b/tests/harness/node-parallel-runner.test.ts @@ -24,13 +24,17 @@ test("runs that end by different signals, or by a signal and an exit, differ", ( expect(termination({ code: 0, signal: null })).toBe(0); }); -test("a relative --node path links the checkout's fixtures into the minimal tree", () => { +test("a relative --node path links the checkout's fixtures and common helpers into the minimal tree", () => { const checkout = join(scratch, "node"); mkdirSync(join(checkout, "test", "parallel"), { recursive: true }); mkdirSync(join(checkout, "test", "fixtures"), { recursive: true }); mkdirSync(join(checkout, "test", "common"), { recursive: true }); writeFileSync(join(checkout, "test", "fixtures", "marker.txt"), "fixture\n"); - writeFileSync(join(checkout, "test", "common", "fixtures.js"), "module.exports = {};\n"); + for (const helper of ["fixtures.js", "dns.js", "internet.js"]) + writeFileSync( + join(checkout, "test", "common", helper), + `module.exports = ${JSON.stringify(helper)};\n`, + ); const out = join(scratch, "out"); const result = spawnSync( process.execPath, @@ -49,4 +53,9 @@ test("a relative --node path links the checkout's fixtures into the minimal tree const linked = join(out, "tree", "test", "fixtures", "marker.txt"); expect(existsSync(linked)).toBe(true); expect(readFileSync(linked, "utf8")).toBe("fixture\n"); + for (const helper of ["dns.js", "internet.js"]) + expect(readFileSync(join(out, "tree", "test", "common", helper), "utf8")).toBe( + `module.exports = ${JSON.stringify(helper)};\n`, + ); + expect(existsSync(join(out, "tree", "test", "common", "tmpdir.js"))).toBe(true); }); diff --git a/tests/node-parallel/README.md b/tests/node-parallel/README.md index c8ac12134..f24f6b29e 100644 --- a/tests/node-parallel/README.md +++ b/tests/node-parallel/README.md @@ -12,7 +12,7 @@ Options: - `--node`: a Node.js checkout, or its `test` directory. Use the version in `.node-version`. - `--pattern`: a regular expression over file names in `test/parallel`. - `--common`: `minimal` (the default) or `node`. - - `minimal` copies the selected files into `/tree/test/parallel` and uses `common/index.cjs` and `common/wpt.cjs` from this directory as `test/common/index.js` and `test/common/wpt.js`, with the checkout's `test/fixtures` and `test/common/fixtures.js`. The minimal common has the helpers these files use (`mustCall`, `mustNotCall`, `skip`, `expectWarning`, `invalidArgTypeHelper`, `spawnPromisified`, `hasIntl`, `isWindows`, ...) with Node's semantics, so a verdict measures the module under test rather than Node's full `test/common`. + - `minimal` copies the selected files into `/tree/test/parallel` and uses `common/index.cjs`, `common/wpt.cjs` and `common/tmpdir.cjs` from this directory as `test/common/index.js`, `test/common/wpt.js` and `test/common/tmpdir.js`, with the checkout's `test/fixtures`, `test/common/fixtures.js`, `test/common/dns.js` and `test/common/internet.js`. The minimal common has the helpers these files use (`mustCall`, `mustNotCall`, `mustSucceed`, `skip`, `expectWarning`, `expectsError`, `invalidArgTypeHelper`, `spawnPromisified`, `hasIntl`, `isWindows`, `PIPE`, ...) with Node's semantics, so a verdict measures the module under test rather than Node's full `test/common`. - `node` builds the files in place against Node's own `test/common`. - `--out`: where executables and results go (default `node_modules/.cache/scriptc-node-parallel`). - `--cli`: the scriptc CLI entry (default `packages/cli/dist/main.js`). diff --git a/tests/node-parallel/common/index.cjs b/tests/node-parallel/common/index.cjs index 098351161..4d14e5639 100644 --- a/tests/node-parallel/common/index.cjs +++ b/tests/node-parallel/common/index.cjs @@ -13,6 +13,7 @@ const isWindows = process.platform === "win32"; const isAIX = os.type() === "AIX"; const isIBMi = os.type() === "OS400"; const isSunOS = process.platform === "sunos"; +const hasCrypto = Boolean(process.versions.openssl); const mustCallChecks = []; function runCallChecks(exitCode) { @@ -60,6 +61,13 @@ function mustCallAtLeast(fn, minimum) { return mustCallInner(fn, minimum, "minimum"); } +function mustSucceed(fn, exact) { + return mustCall(function (err, ...args) { + assert.ifError(err); + if (typeof fn === "function") return fn.apply(this, args); + }, exact); +} + function mustNotCall(msg) { return function mustNotCall() { assert.fail(msg || "function should not have been called"); @@ -89,6 +97,23 @@ function expectWarning(name, expected, code) { }); } +function expectsError(validator, exact) { + return mustCall((...args) => { + if (args.length !== 1) assert.fail(`Expected one argument, got ${inspect(args)}`); + const error = args.pop(); + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(error, "message"), false); + assert.throws(() => { + throw error; + }, validator); + return true; + }, exact); +} + +function platformTimeout(ms) { + if (isAIX || isIBMi) return (typeof ms === "bigint" ? 2n : 2) * ms; + return ms; +} + function invalidArgTypeHelper(input) { if (input == null) return ` Received ${input}`; if (typeof input === "function") return ` Received function ${input.name}`; @@ -122,6 +147,7 @@ function spawnPromisified(...args) { } module.exports = { + hasCrypto, hasIntl, isAIX, isDebug, @@ -131,8 +157,32 @@ module.exports = { mustCall, mustCallAtLeast, mustNotCall, + mustSucceed, skip, expectWarning, + expectsError, invalidArgTypeHelper, + platformTimeout, spawnPromisified, + get hasIPv6() { + const re = isWindows ? /Loopback Pseudo-Interface/ : isIBMi ? /\*LOOPBACK/ : /lo/; + const interfaces = os.networkInterfaces(); + return Object.keys(interfaces).some( + (name) => re.test(name) && interfaces[name].some(({ family }) => family === "IPv6"), + ); + }, + inFreeBSDJail: false, + localhostIPv4: "127.0.0.1", + // The helpers below load their modules on first use, so that files of other + // lanes do not load node:net or the tmpdir helper. + get defaultAutoSelectFamilyAttemptTimeout() { + return require("net").getDefaultAutoSelectFamilyAttemptTimeout(); + }, + get PIPE() { + const path = require("path"); + const pipePrefix = isWindows + ? "\\\\.\\pipe\\" + : path.relative(process.cwd(), `${require("./tmpdir").path}/`); + return path.join(pipePrefix, `node-test.${process.pid}.sock`); + }, }; diff --git a/tests/node-parallel/common/tmpdir.cjs b/tests/node-parallel/common/tmpdir.cjs new file mode 100644 index 000000000..85d19c911 --- /dev/null +++ b/tests/node-parallel/common/tmpdir.cjs @@ -0,0 +1,36 @@ +"use strict"; +// test/common/tmpdir.js for the minimal test/common: a per-run directory +// beside test/parallel, with the semantics of Node's helper. +const fs = require("fs"); +const path = require("path"); + +const testRoot = path.resolve(__dirname, ".."); +let tmpPath = path.join(testRoot, `.tmp.${process.env.TEST_SERIAL_ID || process.env.TEST_THREAD_ID || "0"}`); + +let firstRefresh = true; +function refresh() { + fs.rmSync(tmpPath, { maxRetries: 3, recursive: true, force: true }); + fs.mkdirSync(tmpPath); + if (firstRefresh) { + firstRefresh = false; + process.on("exit", () => { + process.chdir(testRoot); + fs.rmSync(tmpPath, { maxRetries: 3, recursive: true, force: true }); + }); + } +} + +function resolve(...paths) { + return path.resolve(tmpPath, ...paths); +} + +module.exports = { + refresh, + resolve, + get path() { + return tmpPath; + }, + set path(newPath) { + tmpPath = path.resolve(newPath); + }, +}; diff --git a/tests/node-parallel/run.mjs b/tests/node-parallel/run.mjs index e7a1e3cec..dee37c10f 100644 --- a/tests/node-parallel/run.mjs +++ b/tests/node-parallel/run.mjs @@ -42,8 +42,8 @@ const out = resolve(values.out); mkdirSync(out, { recursive: true }); // The minimal lane copies the selected files into a tree whose test/common is -// this directory's index.cjs and wpt.cjs, next to the checkout's fixtures and -// common/fixtures.js. +// this directory's index.cjs, wpt.cjs and tmpdir.cjs, next to the checkout's +// fixtures, common/fixtures.js, common/dns.js and common/internet.js. let testRoot = nodeTest; const files = readdirSync(join(nodeTest, "parallel")) .filter((name) => name.endsWith(".js") && pattern.test(name)) @@ -59,6 +59,9 @@ if (values.common === "minimal") { cpSync(join(nodeTest, "common", "fixtures.js"), join(testRoot, "common", "fixtures.js")); cpSync(join(here, "common", "index.cjs"), join(testRoot, "common", "index.js")); cpSync(join(here, "common", "wpt.cjs"), join(testRoot, "common", "wpt.js")); + cpSync(join(here, "common", "tmpdir.cjs"), join(testRoot, "common", "tmpdir.js")); + for (const helper of ["dns.js", "internet.js"]) + cpSync(join(nodeTest, "common", helper), join(testRoot, "common", helper)); } function run(cmd, args, opts) { From a9c4647e0c0ceff3735684ef63b7d50e7f3ca8d0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 22:57:02 -0500 Subject: [PATCH 188/193] Link bench:runtime layout variants with zig cc on the musl lane The --layouts wrapper always forwarded to clang. A musl target links with `zig cc`, and SCRIPTC_LINKER replaces the driver together with its `cc` argument, so every layout build failed. The wrapper now forwards to `zig cc` when SCRIPTC_TARGET names a musl triple. --- scripts/bench-runtime.mjs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/bench-runtime.mjs b/scripts/bench-runtime.mjs index 63f3fa2d6..02fe78392 100644 --- a/scripts/bench-runtime.mjs +++ b/scripts/bench-runtime.mjs @@ -122,9 +122,9 @@ const work = mkdtempSync( /* ── code-layout perturbation ──────────────────────────────────────────── */ // Layout k > 0 links a retained k*16-byte padding object before every other // input, through a SCRIPTC_LINKER wrapper that forwards to clang (the default -// linker driver on macOS and Linux). The runtime is linked before the program -// object, so both move as if earlier code had grown; any checkout's compiler can -// be perturbed this way. +// linker driver on macOS and Linux) or, for a musl target, to `zig cc`. The +// runtime is linked before the program object, so both move as if earlier code +// had grown; any checkout's compiler can be perturbed this way. function layoutTooling() { if (layouts === 1) return null; const root = join(work, "layouts"); @@ -161,7 +161,13 @@ if (pad && !args.some((a) => a.startsWith("-print-prog-name"))) { } out = ["-Wl,-u,${symbol}", pad, ...args]; } -const result = spawnSync("clang", out, { stdio: "inherit" }); +// The musl lane links with \`zig cc\`; SCRIPTC_LINKER replaces the driver +// together with its \`cc\` argument. +const musl = (process.env.SCRIPTC_TARGET ?? "").includes("musl"); +if (out[0] === "cc") out = out.slice(1); +const result = musl + ? spawnSync("zig", ["cc", ...out], { stdio: "inherit" }) + : spawnSync("clang", out, { stdio: "inherit" }); process.exit(result.status ?? 1); `, ); From 755b7d997bf395332289547adcf277ea7d089a33 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 23:11:27 -0500 Subject: [PATCH 189/193] Read untyped listener arrays for tuple-less events, and route typed meta listeners beside checked-dynamic ones - listeners() and rawListeners() in TypeScript of an event that no literal emit or listener gives a tuple (one registered only under computed names) read the untyped array, so each returned listener takes every argument a call passes. The array was checked into zero-argument functions, which called the listener without its arguments. - A typed 'newListener' or 'removeListener' listener in a program with a checked-dynamic listener of the other meta event registers through the adapter. The meta lowering's check looked only at the listener's own event and refused it with SC2020. --- .../frontend/lowering/lower-event-emitter.ts | 20 +++++++++++---- .../emitter-meta-listeners-mixed.ts.json | 6 +++++ .../events-computed-listeners.ts.json | 6 +++++ tests/corpus/emitter-meta-listeners-mixed.ts | 25 +++++++++++++++++++ .../node-types/events-computed-listeners.ts | 17 +++++++++++++ .../emitter-meta-listeners-mixed.ts.json | 1 + tests/harness/project-config.test.ts | 18 +++++++++++++ 7 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-mixed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/events-computed-listeners.ts.json create mode 100644 tests/corpus/emitter-meta-listeners-mixed.ts create mode 100644 tests/fixtures/node-types/events-computed-listeners.ts create mode 100644 tests/harness/corpus-costs/emitter-meta-listeners-mixed.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 07efba81d..db05519a6 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -141,6 +141,13 @@ function rootsAtError(lowerer: Lowerer, t: IrType | null): boolean { return false; } +/** Whether the scan recorded a dyn-flavored 'newListener' or 'removeListener' + * listener. Then every listener registers through the adapter, typed meta + * listeners included, so each entry has a function value to pass. */ +function hasDynMetaListener(table: Map): boolean { + return [...META_EVENTS].some((meta) => table.get(meta)?.dynListener === true); +} + /** Whether a typed listener of `name` registers through the dyn adapter, so * its entry holds a checked-dynamic function value: a dyn-flavored * 'newListener' or 'removeListener' listener receives every added or @@ -150,7 +157,7 @@ function rootsAtError(lowerer: Lowerer, t: IrType | null): boolean { * conversion admits it or throws a TypeError. */ function registersAsDyn(lowerer: Lowerer, name: string, streamForced: boolean): boolean { const table = emitterEvents(lowerer); - if ([...META_EVENTS].some((meta) => table.get(meta)?.dynListener === true)) return true; + if (hasDynMetaListener(table)) return true; if ((name === "error" || name === ERROR_MONITOR_EVENT) && table.get("error")?.valueEmit === true) return true; const sig = streamForced ? undefined : table.get(name); @@ -856,7 +863,9 @@ function boxedPayload(lowerer: Lowerer, value: IrExpr, node: ts.Expression): IrE * function that was registered. JavaScript sources read the untyped array, * whose functions take every argument a call passes. TypeScript sources * read the element type the typed path declares, the event's tuple: a - * listener of exactly that signature unwraps to the original closure. */ + * listener of exactly that signature unwraps to the original closure. An + * event no literal emit or listener gives a tuple (one registered only under + * computed names) reads the untyped array in both. */ function dynListenerArray( lowerer: Lowerer, table: Map, @@ -872,7 +881,7 @@ function dynListenerArray( type: DYN, loc, }; - if (isJsSourceFile(ts.getSourceFileOfNode(call))) return array; + if (isJsSourceFile(ts.getSourceFileOfNode(call)) || !table.has(name)) return array; const tuple = tupleOf(lowerer, table, name, call); return { kind: "dynCheck", @@ -1007,8 +1016,9 @@ function lowerDynListenerCall( // receives the event name and the added or removed listener's // checked-dynamic function value, as in Node. Every other listener // then registers through the adapter (registersAsDyn), which the event - // table's scan arranges. - if (registering && emitterEvents(lowerer).get(name)?.dynListener !== true) { + // table's scan arranges when it records a dyn-flavored listener of + // either meta event; a typed listener of the other one arrives here too. + if (registering && !hasDynMetaListener(emitterEvents(lowerer))) { lowerer.noLowering( `'${member}' of '${name}' with a checked-dynamic listener the event table did not record`, cbNode, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-mixed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-mixed.ts.json new file mode 100644 index 000000000..79a90729d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-meta-listeners-mixed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-meta-listeners-mixed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/events-computed-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/events-computed-listeners.ts.json new file mode 100644 index 000000000..e355d8e29 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/events-computed-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/events-computed-listeners.ts" + ], + "diags": [] +} diff --git a/tests/corpus/emitter-meta-listeners-mixed.ts b/tests/corpus/emitter-meta-listeners-mixed.ts new file mode 100644 index 000000000..9a1c8d884 --- /dev/null +++ b/tests/corpus/emitter-meta-listeners-mixed.ts @@ -0,0 +1,25 @@ +// A checked-dynamic listener of one meta event beside a typed listener of +// the other: both receive the event name, and the checked-dynamic one also +// the added or removed listener, as in Node. +import { EventEmitter } from "node:events"; + +const dynamicRemoved: any = (name: unknown, fn: unknown) => + console.log("removed", name, typeof fn); +const first = new EventEmitter(); +first.on("newListener", (name: string) => console.log("new", name)); +first.on("removeListener", dynamicRemoved); +const f = (n: number) => console.log("x", n); +first.on("x", f); +first.emit("x", 1); +first.removeListener("x", f); + +const dynamicAdded: any = (name: unknown, fn: unknown) => console.log("added", name, typeof fn); +const second = new EventEmitter(); +second.on("newListener", dynamicAdded); +second.on("removeListener", (name: string) => console.log("gone", name)); +const g = (s: string) => console.log("y", s); +second.on("y", g); +second.emit("y", "two"); +second.off("y", g); +second.removeAllListeners("y"); +console.log(first.listenerCount("x"), second.listenerCount("y")); diff --git a/tests/fixtures/node-types/events-computed-listeners.ts b/tests/fixtures/node-types/events-computed-listeners.ts new file mode 100644 index 000000000..17d3b3ba6 --- /dev/null +++ b/tests/fixtures/node-types/events-computed-listeners.ts @@ -0,0 +1,17 @@ +// listeners() and rawListeners() of an event registered only under computed +// names: no literal emit or listener fixes its arguments, so each returned +// listener takes every argument a call passes, as in Node. +import { EventEmitter } from "node:events"; +const ee = new EventEmitter(); +const keys = ["a", "b"]; +for (const k of keys) ee.on(`evt:${k}`, (x: number, y: number) => console.log("listener", k, x, y)); +function run(): void { + const fns = ee.listeners("evt:a"); + console.log("count", fns.length); + fns[0]!(1, 2); + const typed = ee.listeners("evt:b") as Array<(x: number, y: number) => void>; + typed[0]!(3, 4); + for (const fn of ee.rawListeners("evt:a")) fn(5, 6); + console.log("same", fns[0] === ee.listeners("evt:a")[0]); +} +run(); diff --git a/tests/harness/corpus-costs/emitter-meta-listeners-mixed.ts.json b/tests/harness/corpus-costs/emitter-meta-listeners-mixed.ts.json new file mode 100644 index 000000000..a82c79997 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-meta-listeners-mixed.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 7100 } diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 03f05bdc6..d68ae59f1 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -254,6 +254,24 @@ test("node-types: events.once over typed EventEmitter listeners under @types/nod expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: listener arrays of computed-name events take every argument under @types/node", async () => { + const outDir = outDirFor("events-computed-listeners"); + const entry = join(nodeTypesDir, "events-computed-listeners.ts"); + const result = await compile(entry, { + outPath: join(outDir, "events-computed-listeners"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: events.once with a signal over streams, raced, under @types/node", async () => { const outDir = outDirFor("events-once-signal-race"); const entry = join(nodeTypesDir, "events-once-signal-race.ts"); From f67678b4227bfe415d684f406c797b397ff6f776 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 23:16:13 -0500 Subject: [PATCH 190/193] Pipe decoded socket strings and give lane jobs their own tmpdirs After socket.setEncoding(enc), socket.pipe(destination) wrote the raw bytes it read to the destination. It now writes the decoded strings, as Node's pipe writes each string chunk: the destination receives their UTF-8 bytes, and a partial character the decoder holds at EOF is written before the destination ends. tests/node-parallel/run.mjs runs each job's files with its own TEST_THREAD_ID, as Node's test runner does, so test/common/tmpdir gives concurrent files different directories instead of one shared .tmp.0. --- .../generated/node-v24-internal.json | 111 ++++++++++++------ internal/compatibility/static-support.json | 2 +- .../corpus/net-socket-encoding-pipe.cjs.json | 6 + packages/runtime/src/scr_net.c | 54 ++++++--- tests/corpus/net-socket-encoding-pipe.cjs | 44 +++++++ .../net-socket-encoding-pipe.cjs.json | 1 + tests/harness/node-parallel-runner.test.ts | 47 ++++++++ tests/node-parallel/README.md | 2 +- tests/node-parallel/run.mjs | 13 +- 9 files changed, 220 insertions(+), 60 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-encoding-pipe.cjs.json create mode 100644 tests/corpus/net-socket-encoding-pipe.cjs create mode 100644 tests/harness/corpus-costs/net-socket-encoding-pipe.cjs.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 961b40bd4..3da043d37 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -54790,6 +54790,7 @@ "tests/corpus/2988-http-static-client-abort.ts", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", @@ -56346,7 +56347,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -56434,7 +56436,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -59466,6 +59469,7 @@ "tests/corpus/2689-http-server-timeout-properties.ts", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs" @@ -61731,7 +61735,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -62044,7 +62049,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -63875,6 +63881,7 @@ "tests/corpus/2689-http-server-timeout-properties.ts", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs" @@ -63978,7 +63985,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -64079,7 +64087,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72076,6 +72085,7 @@ "tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", @@ -72692,7 +72702,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72719,7 +72730,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72782,7 +72794,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72809,7 +72822,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72836,7 +72850,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72863,7 +72878,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -72890,7 +72906,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73238,7 +73255,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73283,7 +73301,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73310,7 +73329,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73376,7 +73396,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73403,7 +73424,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73502,7 +73524,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -73586,7 +73609,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74235,7 +74259,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74262,7 +74287,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74289,7 +74315,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74316,7 +74343,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74343,7 +74371,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74370,7 +74399,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74397,7 +74427,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74424,7 +74455,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74451,7 +74483,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74514,7 +74547,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -74541,7 +74575,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -76000,6 +76035,7 @@ "tests/corpus/6397-event-loop-metrics.ts", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", @@ -76311,7 +76347,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -96254,6 +96291,7 @@ "tests/corpus/7442-tls-server-contexts.mjs", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", @@ -96666,7 +96704,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -96698,7 +96737,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" @@ -96753,7 +96793,8 @@ "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", - "tests/corpus/net-server-socket-peer-close.cjs" + "tests/corpus/net-server-socket-peer-close.cjs", + "tests/corpus/net-socket-encoding-pipe.cjs" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 2d74d4de5..50d41994e 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -479,7 +479,7 @@ { "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] }, { "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] }, { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs", "tests/corpus/writable-error-callbacks.ts"] }, - { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs"] }, + { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs", "tests/corpus/net-socket-set-encoding-names.ts", "tests/corpus/net-socket-write-rejected.cjs", "tests/corpus/net-socket-method-values.cjs", "tests/corpus/net-paused-socket-exit.cjs", "tests/corpus/net-server-socket-peer-close.cjs", "tests/corpus/net-socket-encoding-pipe.cjs"] }, { "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] }, { "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, { "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-encoding-pipe.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-encoding-pipe.cjs.json new file mode 100644 index 000000000..59d8f4e80 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-socket-encoding-pipe.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-socket-encoding-pipe.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 2c9793d0b..92fd7c9f1 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -1562,6 +1562,19 @@ static void scr_net_sock_fire_data(ScrNetSocket *s, ScrBytes *chunk, ScrStr *tex scr_bytes_release(empty); } +/* One decoded 'data' string after setEncoding: the pipe destination + * writes it, as Node's pipe writes the string chunk (UTF-8), and the + * 'data' listeners receive it. */ +static void scr_net_sock_emit_text(ScrNetSocket *s, ScrStr *text) { + if (s->pipe_dst) { + char *owned; + const char *bytes = scr_str_utf8(text, &owned); + scr_net_sock_write_raw(s->pipe_dst, bytes, text->len); + scr_str_utf8_end(owned); + } + if (s->data_ls.n > 0) scr_net_sock_fire_data(s, NULL, text); +} + /* EOF: 'end' fires now (directly — Node's poll-phase emit), then the * write half ends (allowHalfOpen false), and the fd closes once the FIN * is out. 'close' waits for the sweep. */ @@ -1573,6 +1586,15 @@ static void scr_net_sock_eof(ScrNetSocket *s) { s->native_eof(s->native_ctx); if (scr_exc_pending()) return; } + if (s->encoding != NULL && (s->data_ls.n > 0 || s->pipe_dst != NULL)) { + /* the decoder's end(): a held partial sequence is the last 'data', + * and the pipe destination receives it before its end() */ + ScrStr *tail = scr_strdec_end(s->encoding, s->dec_pending); + s->dec_pending = 0; + if (tail->len > 0) scr_net_sock_emit_text(s, tail); + scr_str_release(tail); + if (scr_exc_pending()) return; + } if (s->pipe_dst) { /* pipe's end-the-destination default */ ScrNetSocket *dst = s->pipe_dst; @@ -1581,14 +1603,6 @@ static void scr_net_sock_eof(ScrNetSocket *s) { scr_net_sock_maybe_finish_write(dst); } } - if (s->encoding != NULL && s->data_ls.n > 0) { - /* the decoder's end(): a held partial sequence is the last 'data' */ - ScrStr *tail = scr_strdec_end(s->encoding, s->dec_pending); - s->dec_pending = 0; - if (tail->len > 0) scr_net_sock_fire_data(s, NULL, tail); - scr_str_release(tail); - if (scr_exc_pending()) return; - } scr_net_fire0_this(&s->end_ls, s, SCR_DYNH_NET_SOCKET); if (scr_exc_pending()) return; if (!s->wr_ending) { @@ -1611,21 +1625,25 @@ static bool scr_net_sock_deliver(ScrNetSocket *s, const char *buf, size_t n) { * next chunk takes the listener path */ return true; } + if (s->encoding != NULL && (s->pipe_dst != NULL || s->data_ls.n > 0)) { + /* the decoder answers the complete characters; a partial sequence + * waits for the next chunk, and an empty answer emits nothing */ + ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); + chunk->is_buffer = true; + memcpy(chunk->data, buf, n); + ScrStr *text = scr_strdec_write(s->encoding, s->dec_pending, chunk); + s->dec_pending = scr_strdec_next(s->encoding, s->dec_pending, chunk); + scr_bytes_release(chunk); + if (text->len > 0) scr_net_sock_emit_text(s, text); + scr_str_release(text); + return !scr_exc_pending(); + } if (s->pipe_dst) scr_net_sock_write_raw(s->pipe_dst, buf, n); if (s->data_ls.n > 0) { ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, buf, n); - if (s->encoding != NULL) { - /* the decoder answers the complete characters; a partial sequence - * waits for the next chunk, and an empty answer emits nothing */ - ScrStr *text = scr_strdec_write(s->encoding, s->dec_pending, chunk); - s->dec_pending = scr_strdec_next(s->encoding, s->dec_pending, chunk); - if (text->len > 0) scr_net_sock_fire_data(s, NULL, text); - scr_str_release(text); - } else { - scr_net_sock_fire_data(s, chunk, NULL); - } + scr_net_sock_fire_data(s, chunk, NULL); scr_bytes_release(chunk); if (scr_exc_pending()) return false; } diff --git a/tests/corpus/net-socket-encoding-pipe.cjs b/tests/corpus/net-socket-encoding-pipe.cjs new file mode 100644 index 000000000..ff81151d9 --- /dev/null +++ b/tests/corpus/net-socket-encoding-pipe.cjs @@ -0,0 +1,44 @@ +// socket.pipe(destination) after socket.setEncoding(enc) writes the +// decoded strings to the destination, as Node's pipe writes each string +// chunk: the destination receives their UTF-8 bytes, a split character +// arrives whole, and a partial character held at EOF is written before the +// destination ends. +"use strict"; +const net = require("net"); + +const source = net.createServer((socket) => { + socket.on("error", () => {}); + socket.write(Buffer.from([0x61, 0xc3])); + setTimeout(() => socket.end(Buffer.from([0xa9, 0x62, 0xe2, 0x82])), 50); +}); + +const received = new Map(); +const collector = net.createServer((socket) => { + const chunks = []; + let label = null; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => { + const all = Buffer.concat(chunks); + const newline = all.indexOf(0x0a); + label = all.subarray(0, newline).toString(); + received.set(label, all.subarray(newline + 1).toString("hex")); + if (received.size === encodings.length) { + for (const name of encodings) console.log(name, received.get(name)); + source.close(); + collector.close(); + } + }); +}); + +const encodings = ["utf8", "hex", "base64", "latin1", "ucs2"]; +source.listen(0, "127.0.0.1", () => { + collector.listen(0, "127.0.0.1", () => { + for (const encoding of encodings) { + const destination = net.connect(collector.address().port, "127.0.0.1"); + destination.write(encoding + "\n"); + const client = net.connect(source.address().port, "127.0.0.1"); + client.setEncoding(encoding); + client.pipe(destination); + } + }); +}); diff --git a/tests/harness/corpus-costs/net-socket-encoding-pipe.cjs.json b/tests/harness/corpus-costs/net-socket-encoding-pipe.cjs.json new file mode 100644 index 000000000..030fd6454 --- /dev/null +++ b/tests/harness/corpus-costs/net-socket-encoding-pipe.cjs.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 3200 } diff --git a/tests/harness/node-parallel-runner.test.ts b/tests/harness/node-parallel-runner.test.ts index 9caebd258..22c44f39d 100644 --- a/tests/harness/node-parallel-runner.test.ts +++ b/tests/harness/node-parallel-runner.test.ts @@ -59,3 +59,50 @@ test("a relative --node path links the checkout's fixtures and common helpers in ); expect(existsSync(join(out, "tree", "test", "common", "tmpdir.js"))).toBe(true); }); + +test("concurrent files get their own test/common tmpdir", () => { + const checkout = join(scratch, "node-tmpdir"); + mkdirSync(join(checkout, "test", "parallel"), { recursive: true }); + mkdirSync(join(checkout, "test", "fixtures"), { recursive: true }); + mkdirSync(join(checkout, "test", "common"), { recursive: true }); + for (const helper of ["fixtures.js", "dns.js", "internet.js"]) + writeFileSync(join(checkout, "test", "common", helper), "module.exports = {};\n"); + // Each file refreshes the tmpdir, writes a marker and reads it back after + // the other file has refreshed its own: with a shared directory the + // marker is gone and the file exits with 1. + const body = `const fs = require("fs"); +const tmpdir = require("../common/tmpdir"); +tmpdir.refresh(); +fs.writeFileSync(tmpdir.resolve("marker"), __filename); +setTimeout(() => { + process.exit(fs.existsSync(tmpdir.resolve("marker")) ? 0 : 1); +}, 500); +`; + for (const name of ["test-tmp-a.js", "test-tmp-b.js"]) + writeFileSync(join(checkout, "test", "parallel", name), body); + const failingCli = join(scratch, "failing-cli.mjs"); + writeFileSync(failingCli, "process.exit(1);\n"); + const out = join(scratch, "out-tmpdir"); + const result = spawnSync( + process.execPath, + [ + join(repo, "tests/node-parallel/run.mjs"), + "--node", + checkout, + "--pattern", + "^test-tmp-", + "--out", + out, + "--cli", + failingCli, + "--jobs", + "2", + ], + { cwd: scratch, encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + const results = JSON.parse(readFileSync(join(out, "results.json"), "utf8")) as { + nodeExit: number; + }[]; + expect(results.map((r) => r.nodeExit)).toEqual([0, 0]); +}); diff --git a/tests/node-parallel/README.md b/tests/node-parallel/README.md index f24f6b29e..664ee41cc 100644 --- a/tests/node-parallel/README.md +++ b/tests/node-parallel/README.md @@ -18,6 +18,6 @@ Options: - `--cli`: the scriptc CLI entry (default `packages/cli/dist/main.js`). - `--jobs`: concurrent files (default 4). -Node runs each file with its `// Flags:` line. Results are written to `/results.json` and `/results.md`. +Node runs each file with its `// Flags:` line. Each job runs its files with its own `TEST_THREAD_ID`, as Node's test runner does, so `test/common/tmpdir` gives concurrent files different directories. Results are written to `/results.json` and `/results.md`. Before a lane relies on the minimal common, run its files under Node in `/tree/test/parallel`: they should all pass there. diff --git a/tests/node-parallel/run.mjs b/tests/node-parallel/run.mjs index dee37c10f..02f967034 100644 --- a/tests/node-parallel/run.mjs +++ b/tests/node-parallel/run.mjs @@ -89,11 +89,14 @@ const firstCause = (text) => .trim() .slice(0, 300); -async function one(file) { +// Each job runs its files with its own TEST_THREAD_ID, as Node's test runner +// does, so test/common/tmpdir gives concurrent files different directories. +async function one(file, job) { + const env = { ...process.env, TEST_THREAD_ID: String(job) }; const dir = join(testRoot, "parallel"); const source = readFileSync(join(dir, file), "utf8"); const flags = (/^\/\/ Flags: (.*)$/m.exec(source)?.[1] ?? "").split(/\s+/).filter(Boolean); - const node = await run(process.execPath, [...flags, file], { cwd: dir, timeout: 120_000 }); + const node = await run(process.execPath, [...flags, file], { cwd: dir, env, timeout: 120_000 }); const exe = join(out, "bin", `${file}.exe`); mkdirSync(join(out, "bin"), { recursive: true }); const build = await run(process.execPath, [values.cli, "build", file, "-o", exe], { @@ -106,7 +109,7 @@ async function one(file) { result.cause = firstCause(build.stdout + build.stderr); return result; } - const native = await run(exe, [], { cwd: dir, timeout: 120_000 }); + const native = await run(exe, [], { cwd: dir, env, timeout: 120_000 }); result.exit = termination(native); result.verdict = sameTermination(node, native) ? "pass" : "fail"; if (result.verdict !== "pass") result.cause = firstCause(native.stderr + native.stdout); @@ -116,9 +119,9 @@ async function one(file) { const results = []; const queue = [...files]; await Promise.all( - Array.from({ length: Number(values.jobs) }, async () => { + Array.from({ length: Number(values.jobs) }, async (_, job) => { for (let file = queue.shift(); file !== undefined; file = queue.shift()) { - const result = await one(file); + const result = await one(file, job); results.push(result); console.log(`${result.verdict.padEnd(8)} ${file}${result.cause ? ` ${result.cause}` : ""}`); } From 2353a045404cd8dd18acc6caa97aa19f8f76a90e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 23:46:36 -0500 Subject: [PATCH 191/193] Keep typed records unboxed in object-mode streams --- .../src/backend/llvm/expr-stream-bridges.ts | 48 +++ .../src/backend/llvm/expr-stream-callbacks.ts | 102 ++++++- .../compiler/src/backend/llvm/lib-stream.ts | 34 +++ .../compiler/src/backend/llvm/record-views.ts | 11 + .../src/frontend/lowering/lower-stream.ts | 88 +++++- .../stream-typed-chunk-boxed-kinds.ts.json | 6 + .../stream-typed-chunk-data-and-pipe.ts.json | 6 + .../stream-typed-chunk-for-await.ts.json | 6 + .../stream-typed-chunk-identity.ts.json | 6 + .../stream-typed-chunk-mismatch.ts.json | 6 + .../stream-typed-chunk-mutation.ts.json | 6 + .../stream-typed-chunk-read-paused.ts.json | 6 + .../corpus/stream-typed-chunk-weakmap.ts.json | 6 + .../corpus/stream-typed-chunk-writev.ts.json | 6 + packages/runtime/src/scr_stream.c | 273 ++++++++++++++---- packages/runtime/src/scr_stream.h | 31 ++ .../corpus/stream-typed-chunk-boxed-kinds.ts | 89 ++++++ .../stream-typed-chunk-data-and-pipe.ts | 55 ++++ tests/corpus/stream-typed-chunk-for-await.ts | 44 +++ tests/corpus/stream-typed-chunk-identity.ts | 54 ++++ tests/corpus/stream-typed-chunk-mismatch.ts | 41 +++ tests/corpus/stream-typed-chunk-mutation.ts | 38 +++ .../corpus/stream-typed-chunk-read-paused.ts | 26 ++ tests/corpus/stream-typed-chunk-weakmap.ts | 53 ++++ tests/corpus/stream-typed-chunk-writev.ts | 39 +++ .../stream-typed-chunk-boxed-kinds.ts.json | 1 + .../stream-typed-chunk-data-and-pipe.ts.json | 1 + .../stream-typed-chunk-for-await.ts.json | 1 + .../stream-typed-chunk-identity.ts.json | 1 + .../stream-typed-chunk-mismatch.ts.json | 1 + .../stream-typed-chunk-mutation.ts.json | 1 + .../stream-typed-chunk-read-paused.ts.json | 1 + .../stream-typed-chunk-weakmap.ts.json | 1 + .../stream-typed-chunk-writev.ts.json | 1 + 34 files changed, 1010 insertions(+), 79 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-boxed-kinds.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-data-and-pipe.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-for-await.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-identity.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mismatch.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mutation.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-read-paused.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-weakmap.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-writev.ts.json create mode 100644 tests/corpus/stream-typed-chunk-boxed-kinds.ts create mode 100644 tests/corpus/stream-typed-chunk-data-and-pipe.ts create mode 100644 tests/corpus/stream-typed-chunk-for-await.ts create mode 100644 tests/corpus/stream-typed-chunk-identity.ts create mode 100644 tests/corpus/stream-typed-chunk-mismatch.ts create mode 100644 tests/corpus/stream-typed-chunk-mutation.ts create mode 100644 tests/corpus/stream-typed-chunk-read-paused.ts create mode 100644 tests/corpus/stream-typed-chunk-weakmap.ts create mode 100644 tests/corpus/stream-typed-chunk-writev.ts create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-boxed-kinds.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-data-and-pipe.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-for-await.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-identity.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-mismatch.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-mutation.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-read-paused.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-weakmap.ts.json create mode 100644 tests/harness/corpus-costs/stream-typed-chunk-writev.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 56fb5458c..2042b9078 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -40,6 +40,7 @@ import { llFieldType, loadStructField, releaseSym, + traceAdapter, traceArg, vAdapters, type ShapeHost, @@ -754,6 +755,50 @@ function symbolKeyLines( }; } +/** The ScrStreamTypedDesc constant (scr_stream.h) of a record type an + * object-mode stream holds as the record itself, or null for a type whose + * chunks stay boxed: records without declared fields, with an index + * signature or accessor slots, tuples, the text codec records (they box as + * handles), and records the collector traces (a stream does not trace the + * records it holds). The constant's `box` converts the record as a + * live-reference conversion to dyn does (streamTypedRefBoxValue). */ +export function streamTypedDesc(host: LlvmEmitterContext, t: IrType): string | null { + if (t.kind !== "record") return null; + const shape = host.recordsById.get(t.shapeId); + if ( + !shape || + shape.fields.length === 0 || + shape.indexValue !== undefined || + shape.tuple || + shapeHasAccessorSlots(shape) || + shape.fields.some((field) => field.name === "handleEvent" && field.type.kind === "func") || + textCodecRecordClass(t, (id) => host.recordsById.get(id)) !== null || + traceAdapter(host.shapeHost, t) !== null + ) + return null; + const key = typeKey(t); + const cacheKey = `std:${key}`; + const existing = host.resolveThunks.get(cacheKey); + if (existing) return `@${existing}`; + const sym = host.contentSymbol("sc_std_", key); + host.resolveThunks.set(cacheKey, sym); + host.journal?.allocated(sym, "streamTypedDesc", [t]); + const B = new BlockBuilder(); + const boxed = streamTypedRefBoxValue(host, B, t, "%p"); + B.terminate(`ret ptr ${boxed}`); + const rc = vAdapters(host.shapeHost, t); + host.resolveThunkDefs.push( + `define internal ptr @${sym}_box(ptr %p) ${FN_ATTRS} { ; box a typed stream chunk ${key}`, + B.render(), + `}`, + ``, + `@${sym} = internal constant { ptr, ${host.sizeType}, ptr, ptr, ptr } { ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr ${rc.retain}, ptr ${rc.release}, ptr @${sym}_box }`, + ``, + ); + host.journal?.built(); + return `@${sym}`; +} + export function streamTypedRefBoxValue( host: LlvmEmitterContext, B: BlockBuilder, @@ -2018,6 +2063,9 @@ export function streamFromArrayAdapter( } registerReplay("dynPromiseAdapter", (host, args) => dynPromiseAdapter(host, args[0] as IrType)); +registerReplay("streamTypedDesc", (host, args) => + (streamTypedDesc(host, args[0] as IrType) ?? "").slice(1), +); registerReplay("liveDynUnionRefAdapter", (host, args) => liveDynUnionRefAdapter(host, args[0] as IrType & { kind: "union" }), ); diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 78dbc3639..c680822ea 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -1,10 +1,12 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { type IrType, isRefCounted, STRING, typeKey } from "../../ir/ir.js"; +import { streamTypedRefEligible } from "../../ir/analysis.js"; import { FN_ATTRS, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { unionPeekLines, unionTagLines } from "./union-repr.js"; import { registerReplay } from "./emit-journal.js"; +import { streamTypedDesc } from "./expr-stream-bridges.js"; export function streamDataAdapter( host: LlvmEmitterContext, @@ -259,6 +261,31 @@ export function streamDoneFnFor( strTag = def!.arms.findIndex((a) => a.kind === "string"); } } + // data of one record type or nothing (a typed completion, + // pinTypedDataCompletion): a record with a typed-chunk descriptor + // completes as a typed chunk, and another record boxes by reference + // (the conversion a dyn `data` parameter receives). + let typedTag = -1; + let typedDesc: string | null = null; + let liveData = false; + if (valueData && dataT?.kind === "union") { + const arms = host.unionsById.get(dataT.unionId)?.arms ?? []; + const record = arms.findIndex((a) => a.kind === "record"); + if (arms.length === 2 && record >= 0 && arms.some((a) => a.kind === "undefinedT")) { + typedDesc = streamTypedDesc(host, arms[record]!); + if (typedDesc !== null) typedTag = record; + else { + // the records a conversion to dyn keeps by reference (Lowerer's + // liveReference) + const armT = arms[record]!; + const shape = armT.kind === "record" ? host.recordsById.get(armT.shapeId) : undefined; + liveData = + streamTypedRefEligible(armT) && + shape !== undefined && + (shape.fields.length > 0 || shape.indexValue !== undefined); + } + } + } const entry = kind === "c" ? "scr_stream_construct_done" @@ -269,15 +296,19 @@ export function streamDoneFnFor( : kind === "d" ? "scr_stream_destroy_done" : kind === "t" - ? valueData - ? "scr_stream_transform_done_value" - : "scr_stream_transform_done" - : valueData - ? "scr_stream_flush_done_value" - : "scr_stream_flush_done"; + ? typedDesc !== null + ? "scr_stream_transform_done_typed" + : valueData + ? "scr_stream_transform_done_value" + : "scr_stream_transform_done" + : typedDesc !== null + ? "scr_stream_flush_done_typed" + : valueData + ? "scr_stream_flush_done_value" + : "scr_stream_flush_done"; const twoSlot = (kind === "t" || kind === "l") && !valueData; host.declare( - `declare void @${entry}(ptr, ptr${twoSlot ? ", ptr, ptr" : valueData ? ", ptr" : ""})`, + `declare void @${entry}(ptr, ptr${typedDesc !== null ? ", ptr, ptr" : twoSlot ? ", ptr, ptr" : valueData ? ", ptr" : ""})`, ); host.declare(`declare ptr @scr_error_retain_v(ptr)`); host.declare(`declare void @scr_union_release(ptr)`); @@ -291,6 +322,7 @@ export function streamDoneFnFor( `entry:`, ` %eslot = alloca ptr`, ...(twoSlot ? [` %bslot = alloca ptr`, ` %sslot = alloca ptr`] : []), + ...(typedDesc !== null ? [` %tslot = alloca ptr`] : []), ` %capsp = getelementptr inbounds %ScrClosure, ptr %self, i64 1`, ` %bx = load ptr, ptr %capsp`, // the stream, borrowed: the box holds it, and the closure (alive for @@ -299,10 +331,12 @@ export function streamDoneFnFor( ` %s = load ptr, ptr %sp`, ` store ptr null, ptr %eslot`, ...(twoSlot ? [` store ptr null, ptr %bslot`, ` store ptr null, ptr %sslot`] : []), + ...(typedDesc !== null ? [` store ptr null, ptr %tslot`] : []), ]; - // A tag-guarded retained peek out of a union argument into a slot. + // A tag-guarded peek out of a union argument into a slot, retained + // unless retain is null. let arm = 0; - const unwrap = (u: string, tag: number, retain: string, slot: string): void => { + const unwrap = (u: string, tag: number, retain: string | null, slot: string): void => { const a = arm++; const union = u === "%e" ? errT : dataT; const nullable = union?.kind === "union" ? host.nullableUnions.get(union.unionId) : null; @@ -315,8 +349,9 @@ export function streamDoneFnFor( ` br i1 %hit${a}, label %yes${a}, label %done${a}`, `yes${a}:`, ...unionPeekLines(nullable, u, `%pv${a}`), - ` %rt${a} = call ptr ${retain}(ptr %pv${a})`, - ` store ptr %rt${a}, ptr ${slot}`, + ...(retain !== null + ? [` %rt${a} = call ptr ${retain}(ptr %pv${a})`, ` store ptr %rt${a}, ptr ${slot}`] + : [` store ptr %pv${a}, ptr ${slot}`]), ` br label %done${a}`, `done${a}:`, ); @@ -330,8 +365,21 @@ export function streamDoneFnFor( host.declare(`declare ptr @scr_str_retain_v(ptr)`); unwrap("%d", strTag, "@scr_str_retain_v", "%sslot"); } + if (typedDesc !== null) unwrap("%d", typedTag, null, "%tslot"); d.push(` %ev = load ptr, ptr %eslot`); - if (valueData) { + if (typedDesc !== null) { + d.push( + ` %tv = load ptr, ptr %tslot`, + ` call void @${entry}(ptr %s, ptr %ev, ptr %tv, ptr ${typedDesc}) ; moves err; borrows s and the record`, + ); + } else if (liveData) { + host.declare(`declare void @scr_dyn_release(ptr)`); + d.push( + ` %dv = call ptr @${host.liveDynUnionRefAdapter(dataT as IrType & { kind: "union" })}(ptr %d)`, + ` call void @${entry}(ptr %s, ptr %ev, ptr %dv) ; moves err; borrows s and the value`, + ` call void @scr_dyn_release(ptr %dv)`, + ); + } else if (valueData) { const conv = host.dyn.toDynHelper(dataT!); host.declare(`declare void @scr_dyn_release(ptr)`); d.push( @@ -840,18 +888,42 @@ export function streamCbThunkFor( host.declare(`declare void @scr_dyn_release(ptr)`); host.declare(`declare zeroext i1 @scr_exc_pending()`); if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`); + // A typed chunk of the declared record type (scr_stream_push_typed) + // is the record itself; any other chunk converts. + const desc = streamTypedDesc(host, p); + if (desc !== null) { + host.declare(`declare ptr @scr_stream_chunk_typed(ptr, ptr, ptr)`); + d.push( + ` br label %ctchk${i}`, + `ctchk${i}:`, + ` %ctt${i} = call ptr @scr_stream_chunk_typed(ptr %s, ptr %chunk, ptr ${desc})`, + ` %cthit${i} = icmp ne ptr %ctt${i}, null`, + ` br i1 %cthit${i}, label %ctok${i}, label %ctconv${i}`, + `ctconv${i}:`, + ); + } d.push( ` %cv${i} = call ptr @scr_stream_chunk_value(ptr %s, ptr %chunk)`, ` %ct${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %cv${i}, ptr null)`, ` call void @scr_dyn_release(ptr %cv${i})`, ` %ctbad${i} = call zeroext i1 @scr_exc_pending()`, - ` br i1 %ctbad${i}, label %ctfail${i}, label %ctok${i}`, + ` br i1 %ctbad${i}, label %ctfail${i}, label %${desc !== null ? "ctconvok" : "ctok"}${i}`, `ctfail${i}:`, ...(hasThis ? [` call void @scr_stream_release_v(ptr %sr)`] : []), ` ret void`, - `ctok${i}:`, ); - passed.push(`${ty} %ct${i}`); + if (desc !== null) { + d.push( + `ctconvok${i}:`, + ` br label %ctok${i}`, + `ctok${i}:`, + ` %ctv${i} = phi ptr [ %ctt${i}, %ctchk${i} ], [ %ct${i}, %ctconvok${i} ]`, + ); + passed.push(`ptr %ctv${i}`); + } else { + d.push(`ctok${i}:`); + passed.push(`${ty} %ct${i}`); + } } else if (isEncPos && p.kind === "string") { // Node's encoding for decoded (Buffer) chunks is 'buffer'; an // object-mode side passes the write's encoding argument ("" for diff --git a/packages/compiler/src/backend/llvm/lib-stream.ts b/packages/compiler/src/backend/llvm/lib-stream.ts index 89a97d002..11566dc5b 100644 --- a/packages/compiler/src/backend/llvm/lib-stream.ts +++ b/packages/compiler/src/backend/llvm/lib-stream.ts @@ -3,6 +3,8 @@ import { InternalCompilerError } from "../../errors.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { pipelinePumpedRuns } from "../../ir/builtin-signatures.js"; +import { emitBorrowedInput } from "./borrowed-inputs.js"; +import { streamTypedDesc } from "./expr-stream-bridges.js"; const STREAM_CANONICAL_CBS: Record = { readable: ["r", "d"], @@ -14,6 +16,10 @@ const STREAM_CANONICAL_CBS: Record { + if (!ok) return; + if ( + ts.isIdentifier(node) && + node !== declaration && + lowerer.checker.getSymbolAtLocation(node) === symbol + ) { + const call = node.parent; + if (!call || !ts.isCallExpression(call) || call.expression !== node) { + ok = false; + return; + } + const data = call.arguments.nodes[1]; + if (data !== undefined) { + let t: IrType | null; + try { + t = lowerer.mapTypeOf(lowerer.typeOf(data)); + } catch { + t = null; + } + if (t?.kind === "undefinedT") return; + const shape = t?.kind === "record" ? lowerer.shapes.get(t.shapeId) : undefined; + if ( + t?.kind !== "record" || + shape === undefined || + shape.fields.length === 0 || + shape.indexValue !== undefined || + (found.record !== null && found.record.shapeId !== t.shapeId) + ) { + ok = false; + return; + } + found.record = t; + } + } + ts.forEachChild(node, visit); + }; + visit(body); + return ok ? found.record : null; +} + /** Whether every reference to the completion callback `symbol` in `body` * calls it with a `data` argument that is a Buffer, a string, null or * undefined (or with no data). */ @@ -989,7 +1073,9 @@ function parseStreamOptions( continue; } const full = tuples[name]!.map((t) => (t === "fn" ? null : t)); - const pinned = out.readableObject ? null : pinAnyDataCompletion(lowerer, name, fnNode); + const pinned = out.readableObject + ? pinTypedDataCompletion(lowerer, name, fnNode) + : pinAnyDataCompletion(lowerer, name, fnNode); let anyChunk: ts.Symbol | undefined; try { // The completion-callback tail: typed by the checker contextually; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-boxed-kinds.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-boxed-kinds.ts.json new file mode 100644 index 000000000..0a6f23ff3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-boxed-kinds.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-boxed-kinds.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-data-and-pipe.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-data-and-pipe.ts.json new file mode 100644 index 000000000..7d3e9902c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-data-and-pipe.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-data-and-pipe.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-for-await.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-for-await.ts.json new file mode 100644 index 000000000..218dcc215 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-for-await.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-for-await.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-identity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-identity.ts.json new file mode 100644 index 000000000..be6571be0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-identity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-identity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mismatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mismatch.ts.json new file mode 100644 index 000000000..88f11ff72 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mismatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-mismatch.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mutation.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mutation.ts.json new file mode 100644 index 000000000..02b9028e9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-mutation.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-mutation.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-read-paused.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-read-paused.ts.json new file mode 100644 index 000000000..29e67ad0e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-read-paused.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-read-paused.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-weakmap.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-weakmap.ts.json new file mode 100644 index 000000000..ef4b5ce10 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-weakmap.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-weakmap.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-writev.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-writev.ts.json new file mode 100644 index 000000000..ac4661baf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-typed-chunk-writev.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stream-typed-chunk-writev.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index a029ece82..57b93d666 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -80,11 +80,13 @@ static ScrBytes *scr_stream_buffer(ScrBytes *b) { /* ── state ────────────────────────────────────────────────────────────── */ typedef struct ScrWEntry { - ScrBytes *chunk; /* owned; a ScrDyn* value on an object-mode writable side */ + ScrBytes *chunk; /* owned; a ScrDyn* value on an object-mode writable side, + * or a typed record when desc is set */ ScrClosure *cb; /* owned or NULL (the user's write(chunk, cb) callback) */ ScrDyn *enc; /* owned or NULL = undefined: the encoding argument an * object-mode write passed (Node hands it to _write * unchanged; null when the callback took its place) */ + const ScrStreamTypedDesc *desc; /* the typed chunk's type, or NULL */ } ScrWEntry; struct ScrStreamState { @@ -150,6 +152,11 @@ struct ScrStreamState { * Buffer, or both kinds: every entry is an owned ScrDyn* * (scr_stream_add_value). Takes precedence over `encoded`. */ bool dyn_entries; + /* The type of the typed records an object-mode buffer holds + * (scr_stream_push_typed), or NULL. Set by the first entry and kept: + * an entry taken out of the buffer is released through it even after + * the buffer turned to dyn entries, which take precedence. */ + const ScrStreamTypedDesc *typed; /* The objectMode or readableObjectMode option made the buffer hold * object entries (a stream without _read is not a seeded * Readable.from source). */ @@ -220,6 +227,9 @@ struct ScrStreamState { bool final_pending; /* _final called, its callback not yet run */ ScrClosure *inflight_cb; /* owned or NULL */ ScrDyn *cur_enc; /* borrowed: the running write's encoding argument */ + /* the running write's typed chunk type, or NULL (a dyn value or a + * Buffer) */ + const ScrStreamTypedDesc *cur_desc; ScrClosure *write_cb; ScrStreamChunkInv write_inv; ScrClosure *final_cb; @@ -341,12 +351,21 @@ static bool scr_stream_is(const ScrEmitter *em) { /* Readable-buffer entry accessors: bytes chunks, strings once an * encoding is set (lengths then count JS string units, Node's encoded - * accounting), or checked-dynamic values in an object-mode buffer that - * holds other values. */ -typedef enum { SCR_ENTRY_BYTES, SCR_ENTRY_STR, SCR_ENTRY_DYN } ScrEntryKind; + * accounting), checked-dynamic values in an object-mode buffer that holds + * other values, or typed records of the buffer's one type (r.typed). */ +typedef enum { SCR_ENTRY_BYTES, SCR_ENTRY_STR, SCR_ENTRY_DYN, SCR_ENTRY_TYPED } ScrEntryKind; static ScrEntryKind scr_stream_rkind(const ScrStreamState *st) { - return st->r.dyn_entries ? SCR_ENTRY_DYN : st->r.encoded ? SCR_ENTRY_STR : SCR_ENTRY_BYTES; + return st->r.dyn_entries ? SCR_ENTRY_DYN + : st->r.typed ? SCR_ENTRY_TYPED + : st->r.encoded ? SCR_ENTRY_STR + : SCR_ENTRY_BYTES; +} + +/* Two descriptors of one type: the same constant, or the same type key + * (separately emitted constants). */ +static bool scr_stream_desc_same(const ScrStreamTypedDesc *a, const ScrStreamTypedDesc *b) { + return a == b || (a->key_len == b->key_len && memcmp(a->key, b->key, a->key_len) == 0); } static size_t scr_stream_entry_len(const ScrStreamState *st, void *e) { @@ -357,21 +376,25 @@ static size_t scr_stream_entry_len(const ScrStreamState *st, void *e) { /* Releases an entry by the kind it had when it left the buffer: a 'data' * listener may call setEncoding() or push another kind of value while the * entry is out. */ -static void scr_stream_entry_release_kind(void *e, ScrEntryKind kind) { +static void scr_stream_entry_release_kind(const ScrStreamState *st, void *e, ScrEntryKind kind) { if (kind == SCR_ENTRY_DYN) scr_dyn_release((ScrDyn *)e); + else if (kind == SCR_ENTRY_TYPED) st->r.typed->release(e); else if (kind == SCR_ENTRY_STR) scr_str_release((ScrStr *)e); else scr_bytes_release((ScrBytes *)e); } static void scr_stream_entry_release(const ScrStreamState *st, void *e) { - scr_stream_entry_release_kind(e, scr_stream_rkind(st)); + scr_stream_entry_release_kind(st, e, scr_stream_rkind(st)); } /* An entry of the given kind as a dyn value (+1; moves entry). */ -static ScrDyn *scr_stream_entry_box(void *entry, ScrEntryKind kind) { +static ScrDyn *scr_stream_entry_box(const ScrStreamState *st, void *entry, ScrEntryKind kind) { if (kind == SCR_ENTRY_DYN) return entry; ScrDyn *d; - if (kind == SCR_ENTRY_STR) { + if (kind == SCR_ENTRY_TYPED) { + d = st->r.typed->box(entry); + st->r.typed->release(entry); + } else if (kind == SCR_ENTRY_STR) { d = scr_dyn_new_str((ScrStr *)entry); scr_str_release((ScrStr *)entry); } else { @@ -394,13 +417,19 @@ static size_t scr_stream_wchunk_len(const ScrStreamState *st, ScrBytes *chunk) { return 1; } -static ScrBytes *scr_stream_wchunk_retain(const ScrStreamState *st, ScrBytes *chunk) { +/* desc: the chunk's type when it is a typed record (an object-mode side's + * typed write), else NULL. */ +static ScrBytes *scr_stream_wchunk_retain(const ScrStreamState *st, ScrBytes *chunk, + const ScrStreamTypedDesc *desc) { + if (desc) return desc->retain(chunk); if (st->w.values) return (ScrBytes *)scr_dyn_retain((ScrDyn *)chunk); return scr_bytes_retain(chunk); } -static void scr_stream_wchunk_release(const ScrStreamState *st, ScrBytes *chunk) { - if (st->w.values) scr_dyn_release((ScrDyn *)chunk); +static void scr_stream_wchunk_release(const ScrStreamState *st, ScrBytes *chunk, + const ScrStreamTypedDesc *desc) { + if (desc) desc->release(chunk); + else if (st->w.values) scr_dyn_release((ScrDyn *)chunk); else scr_bytes_release(chunk); } @@ -442,8 +471,10 @@ static void scr_stream_state_drop(ScrStreamState *st, bool gc) { for (size_t i = 0; i < st->drain_srcs.n; i++) scr_stream_release(st->drain_srcs.src[i]); } for (size_t i = st->w.head; i < st->w.n; i++) { - bool traced = st->w.values && scr_stream_dyn_entry_traced((ScrDyn *)st->w.q[i].chunk); - if (!gc || !traced) scr_stream_wchunk_release(st, st->w.q[i].chunk); + /* typed chunks are of types the collector does not trace */ + const ScrStreamTypedDesc *desc = st->w.q[i].desc; + bool traced = !desc && st->w.values && scr_stream_dyn_entry_traced((ScrDyn *)st->w.q[i].chunk); + if (!gc || !traced) scr_stream_wchunk_release(st, st->w.q[i].chunk, desc); if (!gc && st->w.q[i].cb) scr_closure_release(st->w.q[i].cb); ScrDyn *enc = st->w.q[i].enc; if (enc && (!gc || !scr_stream_dyn_entry_traced(enc))) scr_dyn_release(enc); @@ -490,7 +521,7 @@ void scr_stream_st_trace(ScrStreamState *st, ScrTraceVisit visit, void *ctx) { for (size_t i = 0; i < st->fin.n; i++) visit(st->fin.cb[i], ctx); for (size_t i = st->w.head; i < st->w.n; i++) { if (st->w.q[i].cb) visit(st->w.q[i].cb, ctx); - if (st->w.values && scr_stream_dyn_entry_traced((ScrDyn *)st->w.q[i].chunk)) + if (!st->w.q[i].desc && st->w.values && scr_stream_dyn_entry_traced((ScrDyn *)st->w.q[i].chunk)) visit(st->w.q[i].chunk, ctx); if (st->w.q[i].enc && scr_stream_dyn_entry_traced(st->w.q[i].enc)) visit(st->w.q[i].enc, ctx); } @@ -896,18 +927,20 @@ static void *scr_stream_rbuf_take(ScrStreamState *st, size_t n) { * slot, so at most one of the first two is non-NULL; the compiler's * stream-data listener thunks dispatch on them (typed listeners unwrap * their declared side; dyn listeners box by tag or take the value). */ -static void scr_stream_emit_data(ScrStream *s, void *chunk, ScrEntryKind kind) { +static void scr_stream_emit_data_entry(ScrStream *s, void *chunk, ScrEntryKind kind) { ScrStreamState *st = s->st; ScrBytes *b = NULL; ScrStr *str = NULL; ScrDyn *value = NULL; + /* a typed record, delivered to pipes as one (no 'data' listeners) */ + const ScrStreamTypedDesc *typed = kind == SCR_ENTRY_TYPED ? st->r.typed : NULL; if (kind == SCR_ENTRY_DYN) { value = chunk; if (value->kind == SCR_DYN_BYTES) b = value->v.bytes; else if (value->kind == SCR_DYN_STR) str = value->v.str; } else if (kind == SCR_ENTRY_STR) { str = chunk; - } else { + } else if (kind == SCR_ENTRY_BYTES) { b = chunk; } /* emit('data') with no listeners does nothing; a piped stream delivers @@ -932,9 +965,10 @@ static void scr_stream_emit_data(ScrStream *s, void *chunk, ScrEntryKind kind) { if (scr_exc_pending()) return; continue; /* the pipes array shifted down over i */ } - bool ok = value ? scr_stream_write_dyn(dst, value, NULL) - : b ? scr_stream_write(dst, b, NULL) - : scr_stream_write_str(dst, str, NULL); + bool ok = typed ? scr_stream_write_typed(dst, chunk, typed, NULL) + : value ? scr_stream_write_dyn(dst, value, NULL) + : b ? scr_stream_write(dst, b, NULL) + : scr_stream_write_str(dst, str, NULL); if (scr_exc_pending()) return; i++; if (!ok && !dst->st->destroyed) { @@ -958,6 +992,20 @@ static void scr_stream_emit_data(ScrStream *s, void *chunk, ScrEntryKind kind) { } } +/* A typed record reaches 'data' listeners as its capsule, and the pipes + * then receive that capsule too; without listeners the pipes receive the + * record. */ +static void scr_stream_emit_data(ScrStream *s, void *chunk, ScrEntryKind kind) { + ScrStreamState *st = s->st; + if (kind != SCR_ENTRY_TYPED || !st->r.data_listeners) { + scr_stream_emit_data_entry(s, chunk, kind); + return; + } + ScrDyn *v = st->r.typed->box(chunk); + scr_stream_emit_data_entry(s, v, SCR_ENTRY_DYN); + scr_dyn_release(v); +} + static void scr_stream_emit_readable_nt(ScrStream *s) { ScrStreamState *st = s->st; st->r.need_readable = false; /* Node's emitReadable clears the ask */ @@ -1076,7 +1124,7 @@ static bool scr_stream_add_chunk(ScrStream *s, ScrBytes *chunk, bool front) { if (st->r.emitted_readable) st->r.emitted_readable = false; ScrEntryKind kind = scr_stream_rkind(st); scr_stream_emit_data(s, entry, kind); - scr_stream_entry_release_kind(entry, kind); + scr_stream_entry_release_kind(st, entry, kind); if (scr_exc_pending()) return false; } else { scr_stream_rbuf_push(st, entry, scr_stream_rkind(st), front); @@ -1188,7 +1236,7 @@ static void *scr_stream_read_n_kind(ScrStream *s, double size, ScrEntryKind *kin if (ret != NULL && !st->error_emitted && !st->close_emitted) { scr_stream_emit_data(s, ret, *kind); if (scr_exc_pending()) { - scr_stream_entry_release_kind(ret, *kind); + scr_stream_entry_release_kind(st, ret, *kind); return NULL; } } @@ -1201,7 +1249,7 @@ static void scr_stream_flow(ScrStream *s) { ScrEntryKind kind; void *b = scr_stream_read_n_kind(s, -1, &kind); if (b == NULL) break; - scr_stream_entry_release_kind(b, kind); + scr_stream_entry_release_kind(st, b, kind); } } @@ -1376,6 +1424,10 @@ static void scr_stream_next_fulfill(ScrStreamState *st, ScrPromise *w /*moves*/, void *chunk /*moves or NULL=EOF*/, ScrEntryKind kind) { void *payload; bool dyn = st->r.next_dyn; + if (chunk != NULL && kind == SCR_ENTRY_TYPED) { + chunk = scr_stream_entry_box(st, chunk, kind); + kind = SCR_ENTRY_DYN; + } if (!dyn && chunk != NULL && kind == SCR_ENTRY_DYN) { ScrDyn *d = chunk; if (d->kind == SCR_DYN_BYTES) { @@ -1400,11 +1452,11 @@ static void scr_stream_next_fulfill(ScrStreamState *st, ScrPromise *w /*moves*/, ScrDyn *result = scr_dyn_new_obj(); scr_dyn_obj_set(result, "value", 5, chunk == NULL ? scr_dyn_retain(scr_dyn_undefined()) - : scr_stream_entry_box(chunk, kind)); + : scr_stream_entry_box(st, chunk, kind)); scr_dyn_obj_set(result, "done", 4, scr_dyn_new_bool(chunk == NULL)); payload = result; } else if (dyn) { - payload = chunk == NULL ? scr_stream_from_done() : scr_stream_entry_box(chunk, kind); + payload = chunk == NULL ? scr_stream_from_done() : scr_stream_entry_box(st, chunk, kind); } else { payload = chunk ? (ScrBytes *)chunk : scr_bytes_new(SCR_BYTES_U8, 0); } @@ -1438,7 +1490,7 @@ static void scr_stream_iter_step(ScrStream *s) { void *chunk = scr_stream_read_n_kind(s, -1, &kind); st->r.next_reading = false; if (scr_exc_pending()) { - if (chunk) scr_stream_entry_release_kind(chunk, kind); + if (chunk) scr_stream_entry_release_kind(st, chunk, kind); ScrPromise *w = st->r.next_waiter; st->r.next_waiter = NULL; if (w) { @@ -1984,7 +2036,7 @@ ScrStream *scr_stream_new_object_readable(ScrClosure *read, ScrStreamReadInv rea static void scr_stream_rbuf_to_dyn(ScrStreamState *st) { ScrEntryKind kind = scr_stream_rkind(st); for (size_t i = st->r.first; i < st->r.first + st->r.n; i++) - st->r.buf[i] = scr_stream_entry_box(st->r.buf[i], kind); + st->r.buf[i] = scr_stream_entry_box(st, st->r.buf[i], kind); st->r.dyn_entries = true; st->r.object_kind_set = true; } @@ -2005,7 +2057,7 @@ static void *scr_stream_entry_convert_kind(ScrStreamState *st, void *entry, ScrE return dec; } if (want != SCR_ENTRY_DYN) scr_stream_rbuf_to_dyn(st); - ScrDyn *d = scr_stream_entry_box(entry, *kind); + ScrDyn *d = scr_stream_entry_box(st, entry, *kind); *kind = SCR_ENTRY_DYN; return d; } @@ -2027,14 +2079,14 @@ static int scr_stream_add_entry(ScrStream *s, void *entry /*moves*/, ScrEntryKin } entry = scr_stream_entry_convert(st, entry, &kind); if (st->r.ended) { - scr_stream_entry_release_kind(entry, kind); + scr_stream_entry_release_kind(st, entry, kind); ScrError *e = scr_stream_mkerr("ERR_STREAM_PUSH_AFTER_EOF", "stream.push() after EOF"); scr_stream_error_or_destroy(s, e); scr_error_release(e); return 0; } if (st->destroyed || st->errored) { - scr_stream_entry_release_kind(entry, kind); + scr_stream_entry_release_kind(st, entry, kind); return 0; } st->r.reading = false; @@ -2042,7 +2094,7 @@ static int scr_stream_add_entry(ScrStream *s, void *entry /*moves*/, ScrEntryKin (st->r.data_listeners || st->pipes.n > 0)) { if (st->r.emitted_readable) st->r.emitted_readable = false; scr_stream_emit_data(s, entry, kind); - scr_stream_entry_release_kind(entry, kind); + scr_stream_entry_release_kind(st, entry, kind); if (scr_exc_pending()) return 0; } else { scr_stream_rbuf_push(st, entry, kind, false); @@ -2053,6 +2105,24 @@ static int scr_stream_add_entry(ScrStream *s, void *entry /*moves*/, ScrEntryKin return !st->r.ended && (st->r.length < st->r.hwm || st->r.length == 0); } +/* push(rec) of a typed record: an object-mode buffer that holds records + * of its type, or nothing yet and no encoding, keeps the record itself; + * any other stream takes its capsule, as push(value) takes it (a typed + * buffer turns to dyn entries before an encoding is set). Borrows rec. */ +bool scr_stream_push_typed(ScrStream *s, void *rec, const ScrStreamTypedDesc *desc) { + ScrStreamState *st = s->st; + if (st->has_r && st->r.object_entries && !st->r.dyn_entries && + (st->r.typed ? scr_stream_desc_same(st->r.typed, desc) + : !st->r.object_kind_set && st->r.length == 0 && !st->r.encoded)) { + if (!st->r.typed) st->r.typed = desc; + return scr_stream_add_entry(s, desc->retain(rec), SCR_ENTRY_TYPED) > 0; + } + ScrDyn *v = desc->box(rec); + bool ret = scr_stream_push_dyn(s, v); + scr_dyn_release(v); + return ret; +} + /* push(value) on an object-mode stream: a string or a Buffer keeps its * kind, any other value is a dyn entry. Borrows v. */ int scr_stream_push_object(ScrStream *s, const ScrDyn *v) { @@ -2110,7 +2180,7 @@ static void scr_stream_on_listener(ScrEmitter *em, ScrStr *name) { /* ── writable internals ───────────────────────────────────────────────── */ static void scr_stream_wq_push(ScrStreamState *st, ScrBytes *chunk /*moves*/, ScrClosure *cb /*moves*/, - ScrDyn *enc /*moves*/) { + ScrDyn *enc /*moves*/, const ScrStreamTypedDesc *desc) { if (st->w.n == st->w.cap) { st->w.cap = st->w.cap ? st->w.cap * 2 : 4; st->w.q = realloc(st->w.q, st->w.cap * sizeof *st->w.q); @@ -2119,32 +2189,41 @@ static void scr_stream_wq_push(ScrStreamState *st, ScrBytes *chunk /*moves*/, Sc st->w.q[st->w.n].chunk = chunk; st->w.q[st->w.n].cb = cb; st->w.q[st->w.n].enc = enc; + st->w.q[st->w.n].desc = desc; st->w.n++; } static void scr_stream_transform_callback(ScrStream *s); -static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, ScrClosure *cb /*moves*/); -static ScrDyn *scr_stream_wentry_value(const ScrStreamState *st, ScrBytes *chunk); +static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, ScrClosure *cb /*moves*/, + const ScrStreamTypedDesc *desc); +static ScrDyn *scr_stream_wentry_value(const ScrStreamState *st, ScrBytes *chunk, + const ScrStreamTypedDesc *desc); static ScrDyn *scr_stream_wentry_encoding(const ScrStreamState *st, ScrDyn *enc); /* One write reaching _write/_transform; the callback reads the write's - * encoding argument through w.cur_enc while it runs. */ + * encoding argument through w.cur_enc, and the type of a typed chunk + * through w.cur_desc, while it runs. */ static void scr_stream_do_write(ScrStream *s, ScrBytes *chunk /*moves*/, ScrClosure *cb /*moves*/, - ScrDyn *enc /*moves*/) { + ScrDyn *enc /*moves*/, const ScrStreamTypedDesc *desc) { ScrStreamState *st = s->st; + const ScrStreamTypedDesc *outer_desc = st->w.cur_desc; + st->w.cur_desc = desc; if (enc == NULL) { - scr_stream_do_write_inner(s, chunk, cb); + scr_stream_do_write_inner(s, chunk, cb, desc); + st->w.cur_desc = outer_desc; return; } ScrDyn *outer = st->w.cur_enc; st->w.cur_enc = enc; - scr_stream_do_write_inner(s, chunk, cb); + scr_stream_do_write_inner(s, chunk, cb, desc); st->w.cur_enc = outer; + st->w.cur_desc = outer_desc; scr_dyn_release(enc); } -static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, ScrClosure *cb /*moves*/) { +static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, ScrClosure *cb /*moves*/, + const ScrStreamTypedDesc *desc) { ScrStreamState *st = s->st; st->w.writing = true; st->w.wsync = true; @@ -2153,16 +2232,17 @@ static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, S if (st->is_transform || st->passthrough) st->xf_length = st->r.length; if (st->passthrough && st->transform_cb == NULL) { /* identity: push through, complete synchronously */ - if (st->w.values) scr_stream_push_dyn(s, (ScrDyn *)chunk); + if (desc) scr_stream_push_typed(s, chunk, desc); + else if (st->w.values) scr_stream_push_dyn(s, (ScrDyn *)chunk); else scr_stream_add_chunk(s, chunk, false); - scr_stream_wchunk_release(st, chunk); + scr_stream_wchunk_release(st, chunk, desc); if (!scr_exc_pending()) scr_stream_transform_callback(s); st->w.wsync = false; return; } if (st->is_transform || st->passthrough) { if (st->transform_cb == NULL) { - scr_stream_wchunk_release(st, chunk); + scr_stream_wchunk_release(st, chunk, desc); ScrError *e = scr_stream_mkerr("ERR_METHOD_NOT_IMPLEMENTED", "The _transform() method is not implemented"); st->w.wsync = false; scr_stream_error_or_destroy(s, e); @@ -2170,7 +2250,7 @@ static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, S return; } st->transform_inv(st->transform_cb, s, chunk); - scr_stream_wchunk_release(st, chunk); + scr_stream_wchunk_release(st, chunk, desc); st->w.wsync = false; return; } @@ -2178,17 +2258,17 @@ static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, S /* Writable.prototype._write: this._writev([{ chunk, encoding }], cb) */ ScrDyn *chunks = scr_dyn_new_arr(); ScrDyn *entry = scr_dyn_new_obj(); - scr_dyn_obj_set(entry, "chunk", 5, scr_stream_wentry_value(st, chunk)); + scr_dyn_obj_set(entry, "chunk", 5, scr_stream_wentry_value(st, chunk, desc)); scr_dyn_obj_set(entry, "encoding", 8, scr_stream_wentry_encoding(st, st->w.cur_enc)); scr_dyn_arr_push(chunks, entry); - scr_stream_wchunk_release(st, chunk); + scr_stream_wchunk_release(st, chunk, desc); st->w.writev_inv(st->w.writev_cb, s, chunks); scr_dyn_release(chunks); st->w.wsync = false; return; } if (st->w.write_cb == NULL) { - scr_stream_wchunk_release(st, chunk); + scr_stream_wchunk_release(st, chunk, desc); ScrError *e = scr_stream_mkerr("ERR_METHOD_NOT_IMPLEMENTED", "The _write() method is not implemented"); st->w.wsync = false; scr_stream_error_or_destroy(s, e); @@ -2196,7 +2276,7 @@ static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, S return; } st->w.write_inv(st->w.write_cb, s, chunk); - scr_stream_wchunk_release(st, chunk); + scr_stream_wchunk_release(st, chunk, desc); st->w.wsync = false; } @@ -2263,7 +2343,7 @@ static void scr_stream_error_buffer(ScrStream *s) { while (st->w.head < st->w.n && !scr_exc_pending()) { ScrWEntry e = st->w.q[st->w.head++]; st->w.length -= scr_stream_wchunk_len(st, e.chunk); - scr_stream_wchunk_release(st, e.chunk); + scr_stream_wchunk_release(st, e.chunk, e.desc); if (e.enc) scr_dyn_release(e.enc); if (e.cb) { SCR_WCB_CALL_ERROF(e.cb, st); @@ -2360,11 +2440,13 @@ static SCR_COLD void scr_stream_writev_buffer(ScrStream *s) { } st->w.head = 0; st->w.n = 0; - scr_stream_do_write(s, all, cbs, NULL); + scr_stream_do_write(s, all, cbs, NULL, NULL); } /* A write's chunk and encoding as _writev receives them (+1 each). */ -static ScrDyn *scr_stream_wentry_value(const ScrStreamState *st, ScrBytes *chunk) { +static ScrDyn *scr_stream_wentry_value(const ScrStreamState *st, ScrBytes *chunk, + const ScrStreamTypedDesc *desc) { + if (desc) return desc->box(chunk); if (st->w.values) return scr_dyn_retain((ScrDyn *)chunk); return chunk->is_buffer ? scr_dyn_new_buffer(chunk) : scr_dyn_new_bytes(chunk); } @@ -2451,11 +2533,11 @@ static void scr_stream_writev_batch(ScrStream *s) { if (!(encoding->kind == SCR_DYN_STR && encoding->v.str->len == 6 && memcmp(encoding->v.str->data, "buffer", 6) == 0)) all_buffers = false; - scr_dyn_obj_set(entry, "chunk", 5, scr_stream_wentry_value(st, e.chunk)); + scr_dyn_obj_set(entry, "chunk", 5, scr_stream_wentry_value(st, e.chunk, e.desc)); scr_dyn_obj_set(entry, "encoding", 8, encoding); scr_dyn_obj_set(entry, "callback", 8, scr_stream_writev_entry_cb(e.cb)); scr_dyn_arr_push(chunks, entry); - scr_stream_wchunk_release(st, e.chunk); + scr_stream_wchunk_release(st, e.chunk, e.desc); if (e.enc) scr_dyn_release(e.enc); if (e.cb) { cbs->caps[k] = scr_box_new_obj(&scr_closure_retain_v, &scr_closure_release_v, @@ -2489,7 +2571,7 @@ static void scr_stream_clear_buffer_entries(ScrStream *s) { st->w.head = 0; st->w.n = 0; } - scr_stream_do_write(s, e.chunk, e.cb, e.enc); + scr_stream_do_write(s, e.chunk, e.cb, e.enc, e.desc); } st->w.buffer_processing = false; } @@ -4851,7 +4933,9 @@ static void scr_compose_destroyed(ScrCompose *c, ScrError *err) { /* d._write: head.write(chunk) ? callback() : (ondrain = callback) */ static void scr_compose_write_inv(ScrClosure *cb, ScrStream *s, ScrBytes *chunk) { ScrCompose *c = scr_box_get_ref(cb->caps[0]); - bool ok = scr_stream_writes_values(s) ? scr_stream_write_dyn(c->head, (ScrDyn *)chunk, NULL) + const ScrStreamTypedDesc *desc = s->st->w.cur_desc; + bool ok = desc ? scr_stream_write_typed(c->head, chunk, desc, NULL) + : scr_stream_writes_values(s) ? scr_stream_write_dyn(c->head, (ScrDyn *)chunk, NULL) : scr_stream_write(c->head, chunk, NULL); if (ok) { if (!scr_exc_pending()) scr_stream_write_done(s, NULL); @@ -5214,7 +5298,9 @@ static void scr_dupx_r_eos(ScrClosure *cb, ScrStream *s, ScrError *err) { /* d._write: w.write(chunk) ? callback() : (ondrain = callback) */ static void scr_dupx_write_inv(ScrClosure *cb, ScrStream *s, ScrBytes *chunk) { ScrDuplexify *x = scr_box_get_ref(cb->caps[0]); - bool ok = scr_stream_writes_values(s) ? scr_stream_write_dyn(x->w, (ScrDyn *)chunk, NULL) + const ScrStreamTypedDesc *desc = s->st->w.cur_desc; + bool ok = desc ? scr_stream_write_typed(x->w, chunk, desc, NULL) + : scr_stream_writes_values(s) ? scr_stream_write_dyn(x->w, (ScrDyn *)chunk, NULL) : scr_stream_write(x->w, chunk, NULL); if (ok) { if (!scr_exc_pending()) scr_stream_write_done(s, NULL); @@ -5265,7 +5351,7 @@ static void scr_dupx_pull(ScrDuplexify *x) { return; } bool more; - if (d->st->r.object_entries) { + if (d->st->r.object_entries && kind != SCR_ENTRY_TYPED) { more = scr_stream_add_entry(d, chunk, kind) > 0; } else if (kind == SCR_ENTRY_STR) { more = scr_stream_push_str(d, chunk); @@ -5273,6 +5359,9 @@ static void scr_dupx_pull(ScrDuplexify *x) { } else if (kind == SCR_ENTRY_DYN) { more = scr_stream_push_dyn(d, chunk); scr_dyn_release(chunk); + } else if (kind == SCR_ENTRY_TYPED) { + more = scr_stream_push_typed(d, chunk, x->r->st->r.typed); + x->r->st->r.typed->release(chunk); } else { more = scr_stream_push(d, chunk); scr_bytes_release(chunk); @@ -6755,7 +6844,7 @@ bool scr_stream_emit_value(ScrStream *s, ScrStr *name, ScrDyn *v) { ScrBytes *scr_stream_chunk_bytes(ScrStream *s, ScrBytes *chunk) { if (!scr_stream_writes_values(s)) return scr_bytes_retain(chunk); ScrDyn *v = (ScrDyn *)chunk; - if (v->kind == SCR_DYN_BYTES) return scr_bytes_raw_view(v->v.bytes); + if (!s->st->w.cur_desc && v->kind == SCR_DYN_BYTES) return scr_bytes_raw_view(v->v.bytes); static const char msg[] = "a stream callback declaring a Buffer chunk received an object-mode value that is not a Buffer"; scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); @@ -6763,10 +6852,17 @@ ScrBytes *scr_stream_chunk_bytes(ScrStream *s, ScrBytes *chunk) { } ScrDyn *scr_stream_chunk_value(ScrStream *s, ScrBytes *chunk) { + const ScrStreamTypedDesc *desc = s->st->w.cur_desc; + if (desc) return desc->box(chunk); if (scr_stream_writes_values(s)) return scr_dyn_retain((ScrDyn *)chunk); return scr_dyn_new_buffer(chunk); } +void *scr_stream_chunk_typed(ScrStream *s, ScrBytes *chunk, const ScrStreamTypedDesc *desc) { + const ScrStreamTypedDesc *cur = s->st->w.cur_desc; + return cur && scr_stream_desc_same(cur, desc) ? desc->retain(chunk) : NULL; +} + ScrStr *scr_stream_chunk_encoding(ScrStream *s) { if (!scr_stream_writes_values(s)) return scr_str_new("buffer", 6); ScrDyn *enc = s->st->w.cur_enc; @@ -7018,7 +7114,7 @@ ScrDyn *scr_stream_read_dyn(ScrStream *s, ScrDyn *size) { scr_bytes_release(chunk); return result; } - return scr_stream_entry_box(chunk, kind); + return scr_stream_entry_box(s->st, chunk, kind); } /* read() answers STRINGS once encoded: the static result type here is @@ -7041,6 +7137,10 @@ ScrBytes *scr_stream_read(ScrStream *s, double size) { } ScrEntryKind kind; void *chunk = scr_stream_read_n_kind(s, size, &kind); + if (chunk && kind == SCR_ENTRY_TYPED) { + chunk = scr_stream_entry_box(s->st, chunk, kind); + kind = SCR_ENTRY_DYN; + } if (chunk && kind == SCR_ENTRY_DYN) { /* an object-mode value: a Buffer is the result, anything else is not */ ScrDyn *d = chunk; @@ -7074,6 +7174,8 @@ ScrBytes *scr_stream_read(ScrStream *s, double size) { ScrStream *scr_stream_set_encoding(ScrStream *s, ScrStr *enc) { ScrStreamState *st = s->st; if (!st->has_r) return scr_stream_retain(s); + /* typed records are values like the dyn entries */ + if (st->r.typed && !st->r.dyn_entries) scr_stream_rbuf_to_dyn(st); if (st->r.dyn_entries) { /* an object-mode buffer of values: Buffers pushed later decode */ if (st->r.enc) scr_str_release(st->r.enc); @@ -7215,8 +7317,9 @@ ScrStream *scr_stream_unpipe(ScrStream *src, ScrStream *dst) { /* ── the writable surface ─────────────────────────────────────────────── */ -static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed*/, - ScrClosure *cb /*moves*/, ScrDyn *enc /*borrowed*/) { +static bool scr_stream_write_chunk_desc(ScrStream *s, ScrBytes *chunk /*borrowed*/, + ScrClosure *cb /*moves*/, ScrDyn *enc /*borrowed*/, + const ScrStreamTypedDesc *desc) { ScrStreamState *st = s->st; if (!st->has_w || st->destroyed || st->w.ending) { ScrError *e = st->has_w && !st->destroyed @@ -7236,9 +7339,9 @@ static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed* st->w.length += scr_stream_wchunk_len(st, chunk); if (enc) enc = scr_dyn_retain(enc); if (st->w.writing || st->w.corked > 0 || st->constructing) { - scr_stream_wq_push(st, scr_stream_wchunk_retain(st, chunk), cb, enc); + scr_stream_wq_push(st, scr_stream_wchunk_retain(st, chunk, desc), cb, enc, desc); } else { - scr_stream_do_write(s, scr_stream_wchunk_retain(st, chunk), cb, enc); + scr_stream_do_write(s, scr_stream_wchunk_retain(st, chunk, desc), cb, enc, desc); } /* Node computes the answer AFTER the (possibly synchronous) write: * a sync completion has already drained its bytes from length. Only @@ -7249,6 +7352,11 @@ static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed* return below && !st->destroyed && !st->errored; } +static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed*/, + ScrClosure *cb /*moves*/, ScrDyn *enc /*borrowed*/) { + return scr_stream_write_chunk_desc(s, chunk, cb, enc, NULL); +} + static bool scr_stream_write_chunk(ScrStream *s, ScrBytes *chunk /*borrowed*/, ScrClosure *cb /*moves*/) { return scr_stream_write_chunk_enc(s, chunk, cb, NULL); } @@ -7264,6 +7372,19 @@ static bool scr_stream_write_value(ScrStream *s, ScrDyn *v, ScrClosure *cb) { return scr_stream_write_chunk_enc(s, (ScrBytes *)v, cb, scr_stream_cb_enc(cb)); } +/* write(rec) of a typed record: on an object-mode side the record itself + * is the chunk; another side takes its capsule, as write(value) takes it. + * Borrows rec. */ +bool scr_stream_write_typed(ScrStream *s, void *rec, const ScrStreamTypedDesc *desc, + ScrClosure *cb) { + if (s->st->has_w && s->st->w.object_mode) + return scr_stream_write_chunk_desc(s, rec, cb, scr_stream_cb_enc(cb), desc); + ScrDyn *v = desc->box(rec); + bool ret = scr_stream_write_dyn(s, v, cb); + scr_dyn_release(v); + return ret; +} + /* A Buffer write on a byte-mode side: with decodeStrings: false the * queue holds values, so the Buffer is boxed with its 'buffer' encoding. * Borrows chunk. */ @@ -7642,6 +7763,32 @@ void scr_stream_transform_done_value(ScrStream *s, ScrError *err /*moves*/, ScrD else scr_stream_transform_callback(s); } +void scr_stream_transform_done_typed(ScrStream *s, ScrError *err /*moves*/, void *rec, + const ScrStreamTypedDesc *desc) { + if (!err && rec) scr_stream_push_typed(s, rec, desc); + if (scr_exc_pending()) { + if (err) scr_error_release(err); + return; + } + if (err) scr_stream_write_done(s, err); + else scr_stream_transform_callback(s); +} + +void scr_stream_flush_done_typed(ScrStream *s, ScrError *err /*moves*/, void *rec, + const ScrStreamTypedDesc *desc) { + s->st->flush_pending = false; + if (err) { + scr_stream_error_or_destroy(s, err); + scr_error_release(err); + return; + } + if (rec) scr_stream_push_typed(s, rec, desc); + if (scr_exc_pending()) return; + scr_stream_push_null(s); + if (scr_exc_pending()) return; + scr_stream_finish_maybe(s); +} + void scr_stream_flush_done_value(ScrStream *s, ScrError *err /*moves*/, ScrDyn *v) { s->st->flush_pending = false; if (err) { @@ -8191,7 +8338,7 @@ static void scr_stream_run_tick(ScrStreamTick *t) { if (st->has_r) { ScrEntryKind kind; void *chunk = scr_stream_read_n_kind(s, 0, &kind); - if (chunk) scr_stream_entry_release_kind(chunk, kind); + if (chunk) scr_stream_entry_release_kind(st, chunk, kind); } break; } diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index f5a8207f2..6b8a4dfdc 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -574,6 +574,37 @@ bool scr_stream_chunk_done(const struct ScrDyn *v); void scr_stream_transform_done_value(ScrStream *s, ScrError *err, struct ScrDyn *v); void scr_stream_flush_done_value(ScrStream *s, ScrError *err, struct ScrDyn *v); +/* Typed object-mode chunks. A compiled push, completion or write of a + * record whose type has a descriptor (one internal constant per type, + * emitted by the compiler) keeps the record itself in the stream's + * buffers. A consumer that reads the chunk as a checked-dynamic value + * gets the record's capsule from `box`, the conversion compiled code + * uses for the record (same referent, so the same JS object). A side + * that is not in object mode, or a buffer that holds other values, takes + * the chunk boxed, as push(value) takes it. */ +typedef struct ScrStreamTypedDesc { + const char *key; /* the type key (the capsule's type key) */ + size_t key_len; + void *(*retain)(void *); + void (*release)(void *); + struct ScrDyn *(*box)(void *rec); /* +1 capsule; borrows rec */ +} ScrStreamTypedDesc; +/* push(rec): borrows rec. */ +bool scr_stream_push_typed(ScrStream *s, void *rec, const ScrStreamTypedDesc *desc); +/* write(rec[, cb]): borrows rec; cb moves. */ +bool scr_stream_write_typed(ScrStream *s, void *rec, const ScrStreamTypedDesc *desc, + ScrClosure *cb); +/* callback(err, rec) of a transform or flush: rec borrowed, NULL when the + * callback passed no data; err moves. */ +void scr_stream_transform_done_typed(ScrStream *s, ScrError *err, void *rec, + const ScrStreamTypedDesc *desc); +void scr_stream_flush_done_typed(ScrStream *s, ScrError *err, void *rec, + const ScrStreamTypedDesc *desc); +/* A compiled write or transform callback's chunk declared with desc's + * type: the record (+1) when the write in progress carries one of that + * type, else NULL (the caller converts scr_stream_chunk_value). */ +void *scr_stream_chunk_typed(ScrStream *s, ScrBytes *chunk, const ScrStreamTypedDesc *desc); + /* Runtime-backed streams (the fs streams in scr_fs_stream.c). * - construct: Node's _construct. The hook runs on a tick after * construction; until construct_done's tick, reads, buffered writes, diff --git a/tests/corpus/stream-typed-chunk-boxed-kinds.ts b/tests/corpus/stream-typed-chunk-boxed-kinds.ts new file mode 100644 index 000000000..39bd8789a --- /dev/null +++ b/tests/corpus/stream-typed-chunk-boxed-kinds.ts @@ -0,0 +1,89 @@ +// Arrays, union-typed values, strings and Buffers in object-mode streams, +// alone and mixed with records in one stream, reach their consumers as +// the values that were pushed. +import { Readable, Transform, Writable } from "node:stream"; + +type Pair = { a: number; b: number }; +type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number }; + +const log: string[] = []; +const arrays: number[][] = [[1, 2], [3], []]; +const shapes: Shape[] = [ + { kind: "circle", r: 2 }, + { kind: "square", side: 3 }, +]; +const first: Pair = { a: 1, b: 2 }; +const second: Pair = { a: 3, b: 4 }; +const pairs: Pair[] = [first, second]; + +const arraySource = new Readable({ objectMode: true, read() {} }); +for (const arr of arrays) arraySource.push(arr); +arraySource.push(null); +const arraySink = new Writable({ + objectMode: true, + write(arr: number[], _enc, cb) { + log.push(`array [${arr.join(",")}] same=${arrays.includes(arr)}`); + cb(); + }, +}); + +const shapeSource = new Readable({ objectMode: true, read() {} }); +for (const shape of shapes) shapeSource.push(shape); +shapeSource.push(null); +const area = new Transform({ + objectMode: true, + transform(shape: Shape, _enc, cb) { + const size = shape.kind === "circle" ? shape.r : shape.side; + log.push(`shape ${shape.kind} ${size} same=${shapes.includes(shape)}`); + cb(null, shape); + }, +}); +const shapeSink = new Writable({ + objectMode: true, + write(shape: Shape, _enc, cb) { + log.push(`shape out ${shape.kind} same=${shapes.includes(shape)}`); + cb(); + }, +}); + +const mixed = new Readable({ objectMode: true, read() {} }); +mixed.push(first); +mixed.push("text"); +mixed.push(second); +mixed.push(Buffer.from("buf")); +mixed.push(null); +const mixedSink = new Writable({ + objectMode: true, + write(value: any, _enc, cb) { + if (typeof value === "string") log.push(`mixed string ${value}`); + else if (Buffer.isBuffer(value)) log.push(`mixed buffer ${value.toString()}`); + else log.push(`mixed pair ${value.a},${value.b} same=${pairs.includes(value)}`); + cb(); + }, +}); + +const later = new Readable({ objectMode: true, read() {} }); +later.push("first"); +later.push(first); +later.push(null); +const laterSink = new Writable({ + objectMode: true, + write(value: any, _enc, cb) { + log.push(`later ${typeof value} ${value === first}`); + cb(); + }, +}); + +let finished = 0; +const done = (): void => { + finished++; + if (finished === 4) for (const line of log) console.log(line); +}; +arraySink.on("finish", done); +shapeSink.on("finish", done); +mixedSink.on("finish", done); +laterSink.on("finish", done); +arraySource.pipe(arraySink); +shapeSource.pipe(area).pipe(shapeSink); +mixed.pipe(mixedSink); +later.pipe(laterSink); diff --git a/tests/corpus/stream-typed-chunk-data-and-pipe.ts b/tests/corpus/stream-typed-chunk-data-and-pipe.ts new file mode 100644 index 000000000..ba5db96e0 --- /dev/null +++ b/tests/corpus/stream-typed-chunk-data-and-pipe.ts @@ -0,0 +1,55 @@ +// A 'data' listener and a pipe on one object-mode stream receive the same +// object; a property the listener adds is visible to the untyped +// consumer downstream, and the typed consumer sees the same object. +import { Readable, Writable } from "node:stream"; + +type Point = { x: number; y: number }; + +const points: Point[] = []; +let i = 0; +const source = new Readable({ + objectMode: true, + read() { + if (i < 3) { + const p = { x: i, y: i * i }; + points.push(p); + i++; + this.push(p); + } else { + this.push(null); + } + }, +}); +const log: string[] = []; +let lastData: any = null; +source.on("data", (p: any) => { + lastData = p; + p.label = `p${p.x}`; + log.push(`data ${p.x},${p.y} same=${p === points[p.x]}`); +}); +const typed = new Writable({ + objectMode: true, + write(p: Point, _enc, cb) { + log.push(`typed ${p.x},${p.y} same=${p === points[p.x]} last=${p === lastData}`); + cb(); + }, +}); +const untyped = new Writable({ + objectMode: true, + write(p: any, _enc, cb) { + log.push(`untyped ${p.x} ${p.label} keys=${Object.keys(p).join("/")} same=${p === points[p.x]}`); + cb(); + }, +}); +let finished = 0; +const done = (): void => { + finished++; + if (finished === 2) { + for (const line of log) console.log(line); + console.log(JSON.stringify(points)); + } +}; +typed.on("finish", done); +untyped.on("finish", done); +source.pipe(typed); +source.pipe(untyped); diff --git a/tests/corpus/stream-typed-chunk-for-await.ts b/tests/corpus/stream-typed-chunk-for-await.ts new file mode 100644 index 000000000..f43bcd835 --- /dev/null +++ b/tests/corpus/stream-typed-chunk-for-await.ts @@ -0,0 +1,44 @@ +// for await over an object-mode Readable and over a Transform's output +// yields the objects that were pushed. +import { Readable, Transform } from "node:stream"; + +type Entry = { key: string; value: number }; + +const entries: Entry[] = []; +let n = 0; +const source = new Readable({ + objectMode: true, + read() { + if (n < 4) { + const entry = { key: `k${n}`, value: n * 10 }; + entries.push(entry); + n++; + this.push(entry); + } else { + this.push(null); + } + }, +}); +const outputs: Entry[] = []; +const double = new Transform({ + objectMode: true, + transform(entry: Entry, _enc, cb) { + const out = { key: entry.key.toUpperCase(), value: entry.value * 2 }; + outputs.push(out); + cb(null, out); + }, +}); + +async function main(): Promise { + const copy = new Readable({ objectMode: true, read() {} }); + for (const entry of [{ key: "a", value: 1 }, { key: "b", value: 2 }]) copy.push(entry); + copy.push(null); + for await (const entry of copy) console.log(`copy ${entry.key}=${entry.value}`); + let index = 0; + for await (const entry of source.pipe(double)) { + console.log(`${entry.key}=${entry.value} same=${entry === outputs[index]} source=${entries.includes(entry)}`); + index++; + } + console.log(`count ${index}`); +} +void main(); diff --git a/tests/corpus/stream-typed-chunk-identity.ts b/tests/corpus/stream-typed-chunk-identity.ts new file mode 100644 index 000000000..a22f7bfc4 --- /dev/null +++ b/tests/corpus/stream-typed-chunk-identity.ts @@ -0,0 +1,54 @@ +// Object-mode chunks keep their identity through a Readable, a Transform +// and a Writable: each stage receives the object the previous stage +// pushed or passed to its callback. +import { Readable, Transform, Writable } from "node:stream"; + +type Row = { id: number; qty: number }; + +const pushed: Row[] = []; +const made: Row[] = []; +const seen = new Map(); +let produced = 0; +const source = new Readable({ + objectMode: true, + highWaterMark: 2, + read() { + while (produced < 6) { + produced++; + const row = { id: produced, qty: produced * 3 }; + pushed.push(row); + seen.set(row, produced); + if (!this.push(row)) return; + } + this.push(null); + }, +}); +const lines: string[] = []; +const pass = new Transform({ + objectMode: true, + transform(row: Row, _enc, cb) { + lines.push(`transform ${row.id} same=${row === pushed[row.id - 1]} map=${seen.get(row)}`); + if (row.id % 2 === 0) { + cb(null, row); + return; + } + const next = { id: row.id, qty: row.qty + 1 }; + made.push(next); + cb(null, next); + }, +}); +const sink = new Writable({ + objectMode: true, + highWaterMark: 1, + write(row: Row, _enc, cb) { + const original = row === pushed[row.id - 1]; + const fresh = made.includes(row); + lines.push(`write ${row.id} qty=${row.qty} original=${original} fresh=${fresh}`); + cb(); + }, +}); +sink.on("finish", () => { + for (const line of lines) console.log(line); + console.log(`pushed ${pushed.length} made ${made.length}`); +}); +source.pipe(pass).pipe(sink); diff --git a/tests/corpus/stream-typed-chunk-mismatch.ts b/tests/corpus/stream-typed-chunk-mismatch.ts new file mode 100644 index 000000000..667c96064 --- /dev/null +++ b/tests/corpus/stream-typed-chunk-mismatch.ts @@ -0,0 +1,41 @@ +// A Transform that declares fewer fields than the chunks carry: the +// fields it does not declare stay on the chunks it passes on, so the +// Writable after it, which declares them, reads them, and an untyped +// 'data' listener on the Transform lists them. +import { Readable, Transform, Writable } from "node:stream"; + +type Full = { id: number; name: string; score: number }; +type Narrow = { id: number }; + +let k = 0; +const source = new Readable({ + objectMode: true, + read() { + if (k < 3) { + this.push({ id: k, name: `n${k}`, score: k * 1.5 }); + k++; + } else { + this.push(null); + } + }, +}); +const log: string[] = []; +const narrow = new Transform({ + objectMode: true, + transform(row: Narrow, _enc, cb) { + log.push(`narrow ${row.id} ${JSON.stringify(row)}`); + cb(null, row); + }, +}); +narrow.on("data", (row: any) => log.push(`data ${Object.keys(row).join("/")} ${row.name}`)); +const full = new Writable({ + objectMode: true, + write(row: Full, _enc, cb) { + log.push(`full ${row.id} ${row.name} ${row.score} ${JSON.stringify(row)}`); + cb(); + }, +}); +full.on("finish", () => { + for (const line of log) console.log(line); +}); +source.pipe(narrow).pipe(full); diff --git a/tests/corpus/stream-typed-chunk-mutation.ts b/tests/corpus/stream-typed-chunk-mutation.ts new file mode 100644 index 000000000..d0ca0b0a2 --- /dev/null +++ b/tests/corpus/stream-typed-chunk-mutation.ts @@ -0,0 +1,38 @@ +// Chunks are shared, not copied: a change to an object after push() is +// visible to the consumer that reads it later, and a change a Transform +// makes reaches the next stage. +import { PassThrough, Transform, Writable } from "node:stream"; + +type Cell = { row: number; value: number }; + +const cells: Cell[] = []; +const log: string[] = []; +const bump = new Transform({ + objectMode: true, + transform(cell: Cell, _enc, cb) { + log.push(`transform ${cell.row} value=${cell.value}`); + cell.value += 100; + cb(null, cell); + }, +}); +const sink = new Writable({ + objectMode: true, + write(cell: Cell, _enc, cb) { + log.push(`write ${cell.row} value=${cell.value} same=${cell === cells[cell.row]}`); + cb(); + }, +}); +const source = new PassThrough({ objectMode: true }); +source.pause(); +for (let r = 0; r < 3; r++) { + const cell = { row: r, value: r }; + cells.push(cell); + source.write(cell); + cell.value = r * 10; +} +sink.on("finish", () => { + for (const line of log) console.log(line); + console.log(cells.map((c) => c.value).join(",")); +}); +source.pipe(bump).pipe(sink); +source.end(); diff --git a/tests/corpus/stream-typed-chunk-read-paused.ts b/tests/corpus/stream-typed-chunk-read-paused.ts new file mode 100644 index 000000000..16813698d --- /dev/null +++ b/tests/corpus/stream-typed-chunk-read-paused.ts @@ -0,0 +1,26 @@ +// read() on a paused object-mode stream answers the pushed objects +// themselves, in order, one per call. +import { Readable } from "node:stream"; + +type Job = { id: number; done: boolean }; + +const jobs: Job[] = []; +const source = new Readable({ objectMode: true, read() {} }); +for (let i = 0; i < 3; i++) { + const job = { id: i, done: false }; + jobs.push(job); + source.push(job); +} +console.log(`length ${source.readableLength}`); +const first = source.read(); +console.log(`first ${first.id} same=${first === jobs[0]}`); +first.done = true; +console.log(`jobs[0].done ${jobs[0]!.done}`); +source.push(null); +source.on("readable", () => { + let job: any; + while ((job = source.read()) !== null) { + console.log(`read ${job.id} same=${job === jobs[job.id]} done=${job.done}`); + } +}); +source.on("end", () => console.log("end")); diff --git a/tests/corpus/stream-typed-chunk-weakmap.ts b/tests/corpus/stream-typed-chunk-weakmap.ts new file mode 100644 index 000000000..aa1219c7b --- /dev/null +++ b/tests/corpus/stream-typed-chunk-weakmap.ts @@ -0,0 +1,53 @@ +// A WeakMap and a WeakSet keyed by the objects a Readable pushes find +// them again in a typed Writable, in an untyped 'data' listener, and in +// an untyped Writable downstream. +import { PassThrough, Readable, Writable } from "node:stream"; + +type Item = { name: string; n: number }; + +const tags = new WeakMap(); +const marked = new WeakSet(); +const items: Item[] = []; +for (let i = 0; i < 4; i++) { + const item = { name: `item${i}`, n: i }; + items.push(item); + tags.set(item, `tag-${i}`); + if (i % 2 === 0) marked.add(item); +} +let next = 0; +const source = new Readable({ + objectMode: true, + read() { + const item = items[next++]; + if (item === undefined) this.push(null); + else this.push(item); + }, +}); +const out: string[] = []; +const typed = new Writable({ + objectMode: true, + write(item: Item, _enc, cb) { + out.push(`typed ${item.name} ${tags.get(item)} ${marked.has(item)}`); + cb(); + }, +}); +const middle = new PassThrough({ objectMode: true }); +middle.on("data", (item: any) => { + out.push(`data ${item.name} ${tags.get(item)} ${marked.has(item)}`); +}); +const untyped = new Writable({ + objectMode: true, + write(item: any, _enc, cb) { + out.push(`untyped ${item.name} ${tags.get(item)} ${items.indexOf(item)}`); + cb(); + }, +}); +let finished = 0; +const done = (): void => { + finished++; + if (finished === 2) for (const line of out) console.log(line); +}; +typed.on("finish", done); +untyped.on("finish", done); +source.pipe(typed); +source.pipe(middle).pipe(untyped); diff --git a/tests/corpus/stream-typed-chunk-writev.ts b/tests/corpus/stream-typed-chunk-writev.ts new file mode 100644 index 000000000..8ddca02be --- /dev/null +++ b/tests/corpus/stream-typed-chunk-writev.ts @@ -0,0 +1,39 @@ +// writev on an object-mode Writable receives the written objects in its +// batch, with their encodings, and write() receives them one at a time. +import { Writable } from "node:stream"; + +type Msg = { seq: number; text: string }; + +const sent: Msg[] = []; +const lines: string[] = []; +const batched = new Writable({ + objectMode: true, + write(msg: Msg, _enc, cb) { + lines.push(`write ${msg.seq} same=${msg === sent[msg.seq]}`); + cb(); + }, + writev(chunks: Array<{ chunk: Msg; encoding: any }>, cb) { + const parts = chunks.map( + (c) => `${c.chunk.seq}:${c.chunk.text}:${c.chunk === sent[c.chunk.seq]}:${String(c.encoding)}`, + ); + lines.push(`writev ${parts.join(" ")}`); + cb(); + }, +}); +const make = (seq: number): Msg => { + const msg = { seq, text: `m${seq}` }; + sent.push(msg); + return msg; +}; +batched.write(make(0)); +batched.cork(); +batched.write(make(1)); +batched.write(make(2)); +batched.write(make(3)); +process.nextTick(() => { + batched.uncork(); + batched.end(make(4)); +}); +batched.on("finish", () => { + for (const line of lines) console.log(line); +}); diff --git a/tests/harness/corpus-costs/stream-typed-chunk-boxed-kinds.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-boxed-kinds.ts.json new file mode 100644 index 000000000..81838ef3a --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-boxed-kinds.ts.json @@ -0,0 +1 @@ +{ "plain": 2797, "san": 4727 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-data-and-pipe.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-data-and-pipe.ts.json new file mode 100644 index 000000000..32e63fa22 --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-data-and-pipe.ts.json @@ -0,0 +1 @@ +{ "plain": 2910, "san": 4166 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-for-await.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-for-await.ts.json new file mode 100644 index 000000000..3078acbc2 --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-for-await.ts.json @@ -0,0 +1 @@ +{ "plain": 2833, "san": 5275 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-identity.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-identity.ts.json new file mode 100644 index 000000000..9c68a0fba --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-identity.ts.json @@ -0,0 +1 @@ +{ "plain": 2525, "san": 4060 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-mismatch.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-mismatch.ts.json new file mode 100644 index 000000000..23ca83a0b --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-mismatch.ts.json @@ -0,0 +1 @@ +{ "plain": 2953, "san": 4748 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-mutation.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-mutation.ts.json new file mode 100644 index 000000000..f194675f7 --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-mutation.ts.json @@ -0,0 +1 @@ +{ "plain": 2493, "san": 4446 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-read-paused.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-read-paused.ts.json new file mode 100644 index 000000000..1f697e7e0 --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-read-paused.ts.json @@ -0,0 +1 @@ +{ "plain": 2615, "san": 4274 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-weakmap.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-weakmap.ts.json new file mode 100644 index 000000000..e5eda35cb --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-weakmap.ts.json @@ -0,0 +1 @@ +{ "plain": 2399, "san": 4694 } diff --git a/tests/harness/corpus-costs/stream-typed-chunk-writev.ts.json b/tests/harness/corpus-costs/stream-typed-chunk-writev.ts.json new file mode 100644 index 000000000..d1ac79034 --- /dev/null +++ b/tests/harness/corpus-costs/stream-typed-chunk-writev.ts.json @@ -0,0 +1 @@ +{ "plain": 2486, "san": 4615 } From e36a442468f2a0e446a4b09e54d7d772c6c663ba Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 23:59:44 -0500 Subject: [PATCH 192/193] Throw for primitive Reflect.has targets, record isRaw, and match StringDecoder chunks Reflect.has over a target held as any, unknown or a union answered a presence result for a primitive. It now checks the target at run time after both arguments are evaluated and throws Node's TypeError ("Reflect.has called on non-object"). The stdlib-fence snapshot no longer lists Reflect.has, which lowers. process.stdin.isRaw read a flag that only the dynamic setRawMode path set, so a direct process.stdin.setRawMode(true) on a TTY left it false. The flag now lives with scr_process_stdin_set_raw_mode, which every path calls, readline's raw mode included. StringDecoder.end(chunk) with a dynamic chunk that is undefined threw ERR_INVALID_ARG_TYPE. It now only flushes, as Node's end skips the write for undefined. A dynamic ArrayBuffer or SharedArrayBuffer chunk decoded its contents; write and end now throw Node's ERR_INVALID_ARG_TYPE for it, as it is not an ArrayBufferView. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../frontend/lowering/builtins/reflection.ts | 47 ++++++++++++++++--- .../frontend/lowering/builtins/text-codecs.ts | 25 ++++++++++ packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 2 + packages/runtime/src/scr_bytes_strdec_dyn.c | 6 +-- packages/runtime/src/scr_dyn.h | 2 + packages/runtime/src/scr_events.c | 7 ++- packages/runtime/src/scr_json.c | 19 ++++++++ packages/runtime/src/scr_lib.c | 9 +++- packages/runtime/src/scr_lib.h | 2 + .../corpus/8426-reflect-object-reflections.ts | 19 ++++++++ .../8428-string-decoder-dynamic-chunks.ts | 25 ++++++++++ .../harness/__snapshots__/stdlib-fence.ts.txt | 9 ---- 14 files changed, 149 insertions(+), 25 deletions(-) diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 00e0e9bc5..7231e9820 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1151,6 +1151,7 @@ export const LIB_FN_SYMS: Record = { "dyn.keyDelete": "scr_dyn_key_delete", "dyn.keyDeleteComputed": "scr_dyn_key_delete_computed", "dyn.reflectDeleteProperty": "scr_dyn_reflect_delete_property", + "dyn.reflectHasTarget": "scr_dyn_reflect_has_target", "dyn.hasKeyComputed": "scr_dyn_has_key_computed", "dyn.hasOwnComputed": "scr_dyn_has_own_computed", "dyn.propertyIsEnumerableComputed": "scr_dyn_property_is_enumerable_computed", diff --git a/packages/compiler/src/frontend/lowering/builtins/reflection.ts b/packages/compiler/src/frontend/lowering/builtins/reflection.ts index 2988f9feb..b90c921c2 100644 --- a/packages/compiler/src/frontend/lowering/builtins/reflection.ts +++ b/packages/compiler/src/frontend/lowering/builtins/reflection.ts @@ -10,6 +10,7 @@ import { type IrExpr, STRING, type SrcLoc, + VOID, arrayOf, isUnitType, } from "../../../ir/ir.js"; @@ -65,8 +66,9 @@ export function lowerReflectCall( // Reflect.has(target, key) — the `in` operator's presence query over // the target's checked-dynamic view (own and inherited names, proxy // `has` traps included). A primitive target is Node's TypeError - // ("Reflect.has called on non-object"), which the presence query - // answers false for instead: fenced, never silently divergent. + // ("Reflect.has called on non-object"): a statically primitive target + // is refused, and a target held as any, unknown or a union is checked + // at run time (dyn.reflectHasTarget). if ( reflectMember === "has" && call.arguments.nodes.length === 2 && @@ -85,13 +87,44 @@ export function lowerReflectCall( ); if (!lowerer.dynConvertible(target.type)) lowerer.noLowering("Reflect.has on this target", call.arguments.nodes[0]!); + const key = lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN); + if (target.type.kind !== "dyn" && target.type.kind !== "union") { + return { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [lowerer.coerceToExpected(target, DYN), key], + type: BOOL, + loc, + }; + } + // A target held as any, unknown or a union may be a primitive at run + // time: both arguments are evaluated, then the target is checked. + const targetLocal = lowerer.declareHiddenLocal("%reflectHasTarget", DYN); + const keyLocal = lowerer.declareHiddenLocal("%reflectHasKey", DYN); + const targetRef = varRef(targetLocal.id, DYN, loc); return { - kind: "libCall", - fn: "dyn.hasKeyComputed", - args: [ - lowerer.coerceToExpected(target, DYN), - lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN), + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: targetLocal.id, + init: lowerer.coerceToExpected(target, DYN), + loc, + }, + { kind: "varDecl", localId: keyLocal.id, init: key, loc }, + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "dyn.reflectHasTarget", args: [targetRef], type: VOID, loc }, + loc, + }, ], + result: { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [targetRef, varRef(keyLocal.id, DYN, loc)], + type: BOOL, + loc, + }, type: BOOL, loc, }; diff --git a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts index 1100523e5..ae63652ed 100644 --- a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts +++ b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts @@ -5,6 +5,7 @@ import { staticTextDecoderEncoding } from "../text-decoder-encoding.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; +import { InternalCompilerError } from "../../../errors.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { BOOL, @@ -331,6 +332,30 @@ function stringDecoderHelper( }, loc, }; + // end(undefined) only flushes, as Node's end skips the write when + // its chunk is undefined. + const [write, next] = body.splice(0, 2, { + kind: "varDecl", + localId: "s.0", + init: { kind: "strLit", value: "", type: STRING, loc }, + loc, + }); + if (write?.kind !== "varDecl" || write.init === null || next === undefined) + throw new InternalCompilerError("StringDecoder.end helper without its write"); + locals.find((local) => local.id === "s.0")!.mutable = true; + body.splice(1, 0, { + kind: "if", + cond: { + kind: "dynTest", + test: "undefined", + value: varRef("chunk.0", DYN, loc), + type: BOOL, + loc, + }, + then: [], + else_: [{ kind: "assign", localId: "s.0", value: write.init, loc }, next], + loc, + }); } } if (op === "endString" || op === "writeString") { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 7ea2ec8c5..5c4a6c056 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -748,6 +748,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.keyDelete", "dyn.keyDeleteComputed", "dyn.reflectDeleteProperty", + "dyn.reflectHasTarget", "dyn.hasKeyComputed", "dyn.hasOwnComputed", "dyn.propertyIsEnumerableComputed", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a950b4202..1f18af11a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -255,6 +255,8 @@ function buildLibFnSignatures() { "dyn.keyDelete": { argTypes: [DYN, STRING, BOOL], result: VOID }, "dyn.keyDeleteComputed": { argTypes: [DYN, DYN, BOOL], result: VOID }, "dyn.reflectDeleteProperty": { argTypes: [DYN, DYN], result: BOOL }, + /** Reflect.has's target check: Node's TypeError for a primitive. */ + "dyn.reflectHasTarget": { argTypes: [DYN], result: VOID }, "dyn.hasKeyComputed": { argTypes: [DYN, DYN], result: BOOL }, "dyn.hasOwnComputed": { argTypes: [DYN, DYN], result: BOOL }, "dyn.propertyIsEnumerableComputed": { argTypes: [DYN, DYN], result: BOOL }, diff --git a/packages/runtime/src/scr_bytes_strdec_dyn.c b/packages/runtime/src/scr_bytes_strdec_dyn.c index 1c9bd2998..c23d54523 100644 --- a/packages/runtime/src/scr_bytes_strdec_dyn.c +++ b/packages/runtime/src/scr_bytes_strdec_dyn.c @@ -17,11 +17,9 @@ static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { *is_string = chunk->kind == SCR_DYN_STR; if (*is_string) return NULL; - if ( - chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || - scr_buffer_storage_is(chunk)) { + /* An ArrayBuffer or SharedArrayBuffer is not a view: Node throws. */ + if (chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES) return scr_bytes_buffer_source(chunk); - } scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); return NULL; } diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2bc1d9a31..d59d7030f 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1156,6 +1156,8 @@ void scr_dyn_symbol_key_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_field_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_key_placeholder(ScrDyn *value, ScrDyn *key); bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key); +/* Reflect.has's target check: Node's TypeError pending for a primitive. */ +void scr_dyn_reflect_has_target(const ScrDyn *target); bool scr_dyn_has_own_computed(const ScrDyn *value, const ScrDyn *key); bool scr_dyn_property_is_enumerable_computed(const ScrDyn *value, const ScrDyn *key); void scr_dyn_key_delete_computed(ScrDyn *value, const ScrDyn *key, bool strict); diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index ddc5bf685..dbd5a606d 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1092,7 +1092,6 @@ static const char *const scr_stdio_names[] = { #endif static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; -static SCR_TL bool scr_stdio_raw; static ScrDyn *scr_stdio_refusal(const char *name) { char message[192]; @@ -1524,7 +1523,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc result = scr_stdio_read(arg); } else if (stream->fd == 0 && method == STDIO_RAW) { scr_process_stdin_set_raw_mode(scr_dyn_truthy(arg)); - if (!scr_exc_pending()) { scr_stdio_raw = scr_dyn_truthy(arg); result = scr_dyn_retain(self); } + if (!scr_exc_pending()) result = scr_dyn_retain(self); #ifdef SCR_STDIO_EXTENDED } else if (stream->fd == 0 && method == STDIO_SET_ENCODING) { ScrStr *enc = scr_strdec_encoding(arg); @@ -1555,7 +1554,7 @@ static ScrDyn *scr_stdio_get(void *ptr, const char *key, size_t length) { if (stream->fd == 0 && length == 14 && memcmp(key, "readableLength", 14) == 0) return scr_dyn_new_num((double)scr_stdin_buffered); if (stream->fd == 0 && length == 5 && memcmp(key, "isRaw", 5) == 0) - return scr_process_is_tty(0) ? scr_dyn_new_bool(scr_stdio_raw) : scr_dyn_retain(scr_dyn_undefined()); + return scr_process_is_tty(0) ? scr_dyn_new_bool(scr_process_stdin_raw()) : scr_dyn_retain(scr_dyn_undefined()); if (stream->fd == 0 && length == 9 && memcmp(key, "destroyed", 9) == 0) return scr_dyn_new_bool(scr_stdin_destroyed); if (length == 3 && memcmp(key, "off", 3) == 0) return scr_dyn_retain(scr_stdio_methods[STDIO_OFF]); if (length == 11 && memcmp(key, "addListener", 11) == 0) return scr_dyn_retain(scr_stdio_methods[STDIO_ON]); @@ -1827,7 +1826,7 @@ void scr_process_on_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, doub double scr_process_stdin_is_raw(void) { if (!scr_context_is_main()) return -1; if (!scr_process_is_tty(0)) return -1; - return scr_stdio_raw ? 1.0 : 0.0; + return scr_process_stdin_raw() ? 1.0 : 0.0; } /* process.prependListener("exit", cb): the same listener in FRONT of the diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 090bfbfc8..9198bd145 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -9403,6 +9403,25 @@ static bool scr_dyn_key_probe_computed(const ScrDyn *value, const ScrDyn *raw_ke bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 0); } bool scr_dyn_has_own_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 1); } + +/* Reflect.has's target check: Node's TypeError for a primitive target. */ +void scr_dyn_reflect_has_target(const ScrDyn *target) { + switch (target->kind) { + case SCR_DYN_NULL: + case SCR_DYN_UNDEF: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: { + static const char message[] = "Reflect.has called on non-object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return; + } + default: + return; + } +} bool scr_dyn_property_is_enumerable_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 2); } static void scr_json_delete_member(ScrDyn *object, const ScrStr *key); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index f8792d129..6556605ab 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5347,7 +5347,12 @@ double scr_process_rows(double fd) { return scr_process_dimension(fd, true); } * setRawMode(false) restores the termios saved at the first raw entry * (libuv's orig_termios), a no-op when raw mode was never entered. * NON-TTY stdin: Node's process.stdin is a Socket with no setRawMode - * member at all, so the call throws Node's exact catchable TypeError. */ + * member at all, so the call throws Node's exact catchable TypeError. + * A TTY call records the mode for process.stdin.isRaw. */ +static SCR_TL bool scr_stdin_raw; + +bool scr_process_stdin_raw(void) { return scr_stdin_raw; } + #ifdef _WIN32 static SCR_TL DWORD scr_stdin_cooked; static SCR_TL bool scr_stdin_cooked_saved = false; @@ -5358,6 +5363,7 @@ void scr_process_stdin_set_raw_mode(bool raw) { scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); return; } + scr_stdin_raw = raw; /* libuv's uv_tty_set_mode(UV_TTY_MODE_RAW) console half: drop line * buffering, echo, and Ctrl-C cooking; restore the entry mode on the * way back — the termios save/restore shape, translated. */ @@ -5391,6 +5397,7 @@ void scr_process_stdin_set_raw_mode(bool raw) { scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); return; } + scr_stdin_raw = raw; if (raw) { struct termios t; if (tcgetattr(0, &t) != 0) return; diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index d6b55a21d..9250b1bca 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -401,6 +401,8 @@ bool scr_process_is_tty(double fd); bool scr_process_is_tty_value(const ScrDyn *fd); void scr_process_stdin_destroy(void); void scr_process_stdin_set_raw_mode(bool raw); +/* The mode the last TTY setRawMode call set (process.stdin.isRaw). */ +bool scr_process_stdin_raw(void); /* Terminal width for process.stdout/stderr.columns (fd 1/2): * ioctl(TIOCGWINSZ), -1 when the fd is not a TTY or the ioctl refuses — diff --git a/tests/corpus/8426-reflect-object-reflections.ts b/tests/corpus/8426-reflect-object-reflections.ts index bae308d81..6dea53e79 100644 --- a/tests/corpus/8426-reflect-object-reflections.ts +++ b/tests/corpus/8426-reflect-object-reflections.ts @@ -26,3 +26,22 @@ console.log(Object.getOwnPropertyNames(list).length); const descriptors = Object.getOwnPropertyDescriptors(record); console.log(Object.keys(descriptors).join(",")); console.log(Object.getOwnPropertySymbols(record).length); +// A primitive target held as any, unknown or a union throws Node's +// TypeError; its key is evaluated first. +function hasThrough(target: unknown, name: string): string { + try { + return String(Reflect.has(target as any, name)); + } catch (e) { + return `${(e as Error).name}: ${(e as Error).message}`; + } +} +const loose: any = "text"; +const either: string | { a: number } = Math.random() > 2 ? { a: 1 } : "text"; +console.log(hasThrough(loose, "length"), hasThrough(42, "x"), hasThrough(undefined, "x")); +console.log(hasThrough({ a: 1 }, "a"), hasThrough(either, "a")); +let keys = 0; +try { + Reflect.has(loose, String(++keys)); +} catch (e) { + console.log((e as Error).name, keys); +} diff --git a/tests/corpus/8428-string-decoder-dynamic-chunks.ts b/tests/corpus/8428-string-decoder-dynamic-chunks.ts index 6f97f1079..7ec060e56 100644 --- a/tests/corpus/8428-string-decoder-dynamic-chunks.ts +++ b/tests/corpus/8428-string-decoder-dynamic-chunks.ts @@ -44,3 +44,28 @@ try { const err = e as Error; console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); } +// end(undefined) only flushes; null is not a chunk. +const flushed = new StringDecoder("utf8"); +console.log(JSON.stringify(write(flushed, Buffer.from([0x61, 0xe2, 0x82])))); +console.log(JSON.stringify(endWith(flushed, undefined))); +try { + endWith(new StringDecoder("utf8"), null); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +// An ArrayBuffer or SharedArrayBuffer is not an ArrayBufferView. +for (const chunk of [new ArrayBuffer(2), new SharedArrayBuffer(2)]) { + try { + write(new StringDecoder("utf8"), chunk); + } catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); + } + try { + endWith(new StringDecoder("utf8"), chunk); + } catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); + } +} diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 3daa5357e..35295f87b 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -7,15 +7,6 @@ stdlib-fence.ts:17:23 - error SC2020: 'Object.assign' is part of the standard li hint: spread instead: { ...a, ...b } builds the merged record; what lowers: the empty-target literal-source shape (Object.assign({}, { ... }) IS the source literal) and merges INTO an index-signature record whose value slot every source value enters — other aliased targets are real mutation, and a function target (Object.assign(fn, { prop })) is a function-with-properties value the model cannot represent: bind the property separately -stdlib-fence.ts:18:14 - error SC2020: 'Reflect.has' is part of the standard library types but has no scriptc lowering yet - - 17 | const aliasedAssign = Object.assign(merged, { b: 2 }); // aliased targets stay fenced (real mutation of a live object) - 18 | const refl = Reflect.has({ a: 1 }, "a"); - | ^~~~~~~~~~~ - 19 | const parsedMs = Date.parse; - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - stdlib-fence.ts:19:18 - error SC2020: 'Date.parse' is part of the standard library types but has no scriptc lowering yet 18 | const refl = Reflect.has({ a: 1 }, "a"); From 3ddebf4c31e05f2457680c4f665a61d93e8f47c0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 00:07:34 -0500 Subject: [PATCH 193/193] Read a captured handle let as the checked-dynamic value unless the checker types the read as the handle Inside a nested function, a read of a captured handle `let` narrowed to the handle whenever it was a member receiver. A function declared before the first write reads the binding as `any`, so the narrowed handle reached member reads typed `any`, and an optional read (`c?.remoteAddress`) compiled to an unconditional SC1090 throw. js-dyn-server, whose listener reads `server.listening` inside the callback passed to createServer, hit the same path. The read now narrows only when the checker types it as the handle. An optional member read keeps the tagged value, so undefined short-circuits and the handle arm narrows; other reads take the checked-dynamic value. The lowerer's closureDynLets collection is a checked journal table. Corpus evolving-let-socket-optional-reads.cjs reads and calls members through `?.` on a captured socket before and after its first write. --- .../lowering/expressions/identifiers.ts | 10 +++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 1 + ...volving-let-socket-optional-reads.cjs.json | 6 ++++++ .../evolving-let-socket-optional-reads.cjs | 20 +++++++++++++++++++ ...volving-let-socket-optional-reads.cjs.json | 1 + 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json create mode 100644 tests/corpus/evolving-let-socket-optional-reads.cjs create mode 100644 tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index eb3f7e7aa..89239246d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -24,6 +24,7 @@ import { funcOf, isUnitType, recordTextCodecClass, + typeEquals, } from "../../../ir/ir.js"; import { lowerNodeModuleIdentifier } from "../lower-node-module.js"; import { lowerCjsExportsObjectValue } from "../cjs-exports-object.js"; @@ -1251,7 +1252,14 @@ function closureDynRead( if (scopeOf(expr) === scopeOf(decl)) return null; const parent = expr.parent; const handle = lowerer.unions.get(type.unionId)?.arms.find((arm) => !isUnitType(arm)); - if (handle && ts.isPropertyAccessExpression(parent) && parent.expression === expr) { + // Only a use the checker types as the handle narrows: inside a function + // declared before the first write the binding reads as `any`. + const typedAsHandle = + handle !== undefined && typeEquals(lowerer.mapTypeOf(lowerer.typeOf(expr)) ?? DYN, handle); + if (typedAsHandle && ts.isPropertyAccessExpression(parent) && parent.expression === expr) { + // An optional read (`c?.address`) tests the tagged value: undefined + // short-circuits and the handle arm narrows. + if (parent.questionDotToken) return { kind: "varRef", localId: id, type, loc }; const helper = lowerer.narrowedArmHelper( type.unionId, handle, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0b73bc0e4..3fcc3e752 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4414,6 +4414,7 @@ export class Lowerer { checkedTable("preparedClassFactories", () => this.preparedClassFactories.size), checkedTable("preparingClassFactories", () => this.preparingClassFactories.size), checkedTable("varGlobalEntryInits", () => this.varGlobalEntryInits.size), + checkedTable("closureDynLets", () => this.closureDynLets.size), checkedTable("cycleInitFlags", () => this.cycleInitFlags.size), checkedTable("classInitFlags", () => this.classInitFlags.size), checkedTable("cycleInitDeclarations", () => this.cycleInitDeclarations.size), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json new file mode 100644 index 000000000..b5701c7db --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/evolving-let-socket-optional-reads.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/evolving-let-socket-optional-reads.cjs b/tests/corpus/evolving-let-socket-optional-reads.cjs new file mode 100644 index 000000000..959191850 --- /dev/null +++ b/tests/corpus/evolving-let-socket-optional-reads.cjs @@ -0,0 +1,20 @@ +'use strict'; +// Optional reads and calls on a captured `let` socket, before and after +// its first write: undefined short-circuits, the socket answers. +const net = require('net'); +let c; +const server = net.createServer((s) => s.end()); +function report(label) { + console.log(label, c?.remoteAddress, c?.address()?.family, c?.remotePort > 0); +} +report('before'); +const early = () => console.log('arrow before', c?.remoteAddress ?? 'none'); +early(); +server.listen(0, '127.0.0.1', () => { + c = net.connect(server.address().port, '127.0.0.1', () => { + report('after'); + console.log('inside', c?.remoteAddress, c?.address()?.family); + c.end(); + server.close(); + }); +}); diff --git a/tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json b/tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json new file mode 100644 index 000000000..1f406eb04 --- /dev/null +++ b/tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json @@ -0,0 +1 @@ +{ "plain": 5449, "san": 15501 }