From 0dcf0e95d32711238158915150227b45713ebaae Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:54 -0700 Subject: [PATCH 01/47] =?UTF-8?q?Route=20more=20packages=20and=20values=20?= =?UTF-8?q?through=20Node=20islands=20(NI-4c=E2=80=934g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node islands stack, part 7 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 3 +- .../compiler/src/backend/native/executable.ts | 5 +- .../compiler/src/diagnostics/diagnostic.ts | 22 +++ packages/compiler/src/diagnostics/envelope.ts | 20 ++- packages/compiler/src/diagnostics/render.ts | 20 ++- packages/compiler/src/executable/prepare.ts | 133 ++++++++++++++++-- .../src/frontend/lowering/lower-calls.ts | 16 ++- .../src/frontend/lowering/lower-modules.ts | 7 +- .../frontend/lowering/lower-node-island.ts | 110 ++++++++++++++- .../frontend/lowering/lower-shared-modules.ts | 41 ++++++ .../compiler/src/frontend/lowering/lowerer.ts | 38 ++++- packages/compiler/src/frontend/program.ts | 11 +- .../compiler/src/ir/builtin-signatures.ts | 3 + packages/compiler/src/node-islands/libnode.ts | 2 +- .../6818-node-island-mixed/main.ts.json | 6 + .../6819-node-island-fenced/main.ts.json | 6 + .../6825-node-island-init-fence/main.ts.json | 6 + .../main.ts.json | 6 + .../libnode-common/bridge/scr_node_bridge.cc | 39 +++++ .../runtime-pack-matrix.mjs | 13 +- packages/runtime/src/scr_exception.c | 13 ++ packages/runtime/src/scr_json.c | 3 + packages/runtime/src/scr_node_bridge.h | 7 +- packages/runtime/src/scr_node_island.h | 8 ++ packages/runtime/src/scr_node_island_handle.c | 101 ++++++++++++- .../corpus/6331-jose-root-entry/package.json | 1 + .../6610-npm-execa-child-mixin/package.json | 1 + tests/corpus/6818-node-island-mixed/main.ts | 49 +++++++ .../node_modules/islandlib/index.d.ts | 27 ++++ .../node_modules/islandlib/index.js | 17 +++ .../node_modules/islandlib/lib/counter.js | 10 ++ .../node_modules/islandlib/package.json | 1 + .../node_modules/wraplib/index.d.ts | 1 + .../node_modules/wraplib/index.js | 9 ++ .../node_modules/wraplib/package.json | 1 + tests/corpus/6819-node-island-fenced/main.ts | 10 ++ .../node_modules/fencedkit/index.d.ts | 7 + .../node_modules/fencedkit/index.js | 29 ++++ .../node_modules/fencedkit/package.json | 1 + .../6825-node-island-init-fence/main.ts | 16 +++ .../node_modules/bloblike/index.d.ts | 8 ++ .../node_modules/bloblike/index.js | 19 +++ .../node_modules/bloblike/package.json | 1 + .../node_modules/oncelike/once.js | 17 +++ .../node_modules/oncelike/package.json | 1 + .../node_modules/pumplike/index.js | 17 +++ .../node_modules/pumplike/package.json | 1 + .../node_modules/tarlike/index.d.ts | 1 + .../node_modules/tarlike/index.js | 13 ++ .../node_modules/tarlike/package.json | 1 + .../6826-node-island-undefined-args/main.ts | 82 +++++++++++ .../node_modules/argprobe/index.d.ts | 23 +++ .../node_modules/argprobe/index.js | 70 +++++++++ .../node_modules/argprobe/package.json | 1 + tests/harness/node-island.test.ts | 2 +- tests/harness/npm.test.ts | 86 ++++++++--- 56 files changed, 1104 insertions(+), 58 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json create mode 100644 tests/corpus/6331-jose-root-entry/package.json create mode 100644 tests/corpus/6610-npm-execa-child-mixin/package.json create mode 100644 tests/corpus/6818-node-island-mixed/main.ts create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json create mode 100644 tests/corpus/6819-node-island-fenced/main.ts create mode 100644 tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts create mode 100644 tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js create mode 100644 tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/main.ts create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json create mode 100644 tests/corpus/6826-node-island-undefined-args/main.ts create mode 100644 tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts create mode 100644 tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js create mode 100644 tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c07b0b96d..107fccf1b 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -958,13 +958,14 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.defer": "scr_node_island_defer", "nodeIsland.import": "scr_ni_import", "nodeIsland.get": "scr_node_island_get", - "nodeIsland.getKey": "scr_ni_get", + "nodeIsland.getKey": "scr_node_island_get_key", "nodeIsland.set": "scr_node_island_set", "nodeIsland.setKey": "scr_node_island_set_key", "nodeIsland.invoke": "scr_node_island_invoke", "nodeIsland.apply": "scr_node_island_apply", "nodeIsland.construct": "scr_node_island_construct", "nodeIsland.instanceof": "scr_node_island_instanceof", + "nodeIsland.isArray": "scr_ni_is_array", "nodeIsland.classMethods": "scr_node_island_class_methods", "nodeIsland.subclass": "scr_node_island_subclass", "nodeIsland.superConstruct": "scr_node_island_super_construct", diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 07a4ce965..4d12ef5ad 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -181,7 +181,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ), unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"), unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"), - unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"), + unit( + (u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch || u.opts.nodeIsland === true, + "scr_dyn_invoke.c", + ), // Buffer.prototype methods called on checked-dynamic values. unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"), // Builtin functions read as values in JavaScript dependencies. diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 06ede8c9a..62d323237 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -105,6 +105,11 @@ export interface ScrDiagnostic { * alone does not say it (a computed require(), a native addon, `with`). * Report-only: never part of the rendered message. */ reason?: string; + /** Runtime fences only: the fenced site is in a module's top-level code + * (outside every function body), so it runs, and throws, when the module + * evaluates. Island selection treats such a fence like one that needs the + * runtime (scope-B NI-4). */ + moduleInit?: true; } /** SC3002–SC3004 — native-helper target, installation, and execution @@ -337,6 +342,23 @@ export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic }; } +/** SC8023 — the build that ran fenced packages as Node islands failed, so + * the program was built natively with those runtime fences (each throws + * when reached). A warning on the native build, with the island build's + * first error. */ +export function nodeIslandRetryFailedDiag( + packages: readonly string[], + first: ScrDiagnostic, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8023", + message: `running ${packages.map((p) => `'${p}'`).join(", ")} in Node islands failed to compile (${first.code} at ${first.loc.file}:${first.loc.start}: ${first.message}); built natively instead, where their runtime fences throw when reached`, + loc, + hint: "fix the island build's error, or mark the packages with --node-island= to see every error", + }; +} + /** SC8003 — a package marked "native" is outside the native subset. */ export function nativePackageDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic { return { diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index 61203b40e..25206701c 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -199,6 +199,19 @@ export interface NodeIslandsEnvelope { unknownPackages: string[]; } +/** Node island notices of a build that succeeds (scope-B): SC8001 and + * SC8021 notes (a package runs in a Node island, an island evaluates at + * boot) and the SC8020 warning (a stateful module duplicated across the + * boundary). The envelope reports all three as warnings. */ +export function isNodeIslandNoticeCode(code: string): boolean { + return code === "SC8001" || code === "SC8020" || code === "SC8021" || code === "SC8023"; +} + +/** SC8001 and SC8021 are notes, not warnings. */ +export function isNodeIslandNoteCode(code: string): boolean { + return code === "SC8001" || code === "SC8021"; +} + /** The divergence band: compiles, but the program can behave differently * from Node. Warnings, never build failures. */ export function isDivergenceCode(code: string): boolean { @@ -331,7 +344,12 @@ export function locateDiagnostic( return { code: diag.code, category: categoryOf(diag), - severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error", + severity: + isDivergenceCode(diag.code) || + isPerformanceCode(diag.code) || + isNodeIslandNoticeCode(diag.code) + ? "warning" + : "error", scope, file: diag.loc.file, line: start.line, diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index e5a75c6bc..232af1b44 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -4,6 +4,7 @@ * longer than FRAME_MAX_WIDTH (minified sources) shows a window of that * width around the diagnostic's column, with "…" where text is cut. */ +import { isNodeIslandNoteCode, isNodeIslandNoticeCode } from "./envelope.js"; import type { ScrDiagnostic } from "./diagnostic.js"; export interface RenderOptions { @@ -102,16 +103,23 @@ export function renderDiagnostic( frame.push( c(DIM, ` ${" ".repeat(gutterWidth)} | `) + " ".repeat(caretCol) + - c(/^SC6\d{3}$/.test(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), + c( + /^SC6\d{3}$/.test(diag.code) || isNodeIslandNoticeCode(diag.code) ? YELLOW : RED, + "^" + "~".repeat(spanOnLine - 1), + ), ); emitLine(lineNum + 1); } // The divergence band (SC6xxx) is the one warning severity: the program // compiles, and the site can behave differently from Node. - const warning = /^SC6\d{3}$/.test(diag.code); + // Node island notices of a successful build: SC8020 warns, SC8001 and + // SC8021 are notes. + const note = isNodeIslandNoteCode(diag.code); + const warning = /^SC6\d{3}$/.test(diag.code) || diag.code === "SC8020" || diag.code === "SC8023"; + const severity = note ? c(CYAN, "note") : warning ? c(YELLOW, "warning") : c(RED, "error"); out.push( - `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`, + `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${severity} ${c(BOLD, diag.code)}: ${diag.message}`, ); if (frame.length) { out.push("", ...frame); @@ -159,9 +167,11 @@ export function renderWarnings( sourceTextByFile: Map, opts: RenderOptions = {}, ): string { - const n = diags.length; + const n = diags.filter((d) => !isNodeIslandNoteCode(d.code)).length; + const rendered = renderDiagnostics([...diags], sourceTextByFile, opts); + if (n === 0) return rendered; return ( - renderDiagnostics([...diags], sourceTextByFile, opts) + + rendered + `\n\n${n} warning${n === 1 ? "" : "s"}: the program compiled, but ${n === 1 ? "this site" : "these sites"} can behave differently from Node.` ); } diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index a01bf303c..90504d0ef 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -4,6 +4,7 @@ import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, nodeIslandNoteDiag, + nodeIslandRetryFailedDiag, npmStaticRefusalDiag, iceDiag, isCheckerPanic, @@ -70,6 +71,8 @@ export function prepareExecutableModule( let lowered: LowerResult; let sourceTexts: Map; let disposed = false; + let fenceGrown: Map | null = null; + const fenceSure = new Set(); // The frontend (and its tsgo server) is released as soon as lowering // ends — clang and the link never hold it open. try { @@ -152,6 +155,31 @@ export function prepareExecutableModule( retryFences, ); } + // A failed program whose compiled packages need the runtime or a + // mutable object model (NI-2's report): try them as islands, and + // report this failure if that build fails too. + const marked = retryFences + ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) + : null; + if (marked !== null) { + const diagnostics = lowered.diagnostics; + const texts = fe.sourceTexts(); + fe.dispose(); + frontendDisposed(); + disposed = true; + const islanded = prepareExecutableModule( + entryPath, + opts, + ffi, + buildPlatform, + createFrontend, + timing, + marked, + false, + ); + if (islanded.ok) return islanded; + return { ok: false, diagnostics, sourceTexts: texts }; + } return fail(lowered.diagnostics); } @@ -181,6 +209,8 @@ export function prepareExecutableModule( if (checked.diagnostics.length > 0) return fail(checked.diagnostics); if (checked.contracts) lowered.module.contracts = checked.contracts; } + if (retryFences) + fenceGrown = fenceIslands(entryPath, opts, fe, lowered, lowerIslands, true, fenceSure); } finally { if (!disposed) { fe.dispose(); @@ -200,17 +230,21 @@ export function prepareExecutableModule( ...(lowered.performance ?? []), ], }; - // Runtime fences in a compiled package that need the JavaScript runtime - // itself (R1: code from strings, computed import()/require(), addons, - // loader hooks) can never run natively: build again with those packages - // as islands, and keep this build when that one cannot compile. Gap - // fences stay deferred refusals until decision 8 (D6) retires them. - const runtimeFences = lowered.runtimeFences.filter( - (d) => classifyDiagnostic(d, "fence").class === "runtime", - ); - if (retryFences && runtimeFences.length > 0) { - const grown = loweringIslands(entryPath, opts, fe, runtimeFences, lowerIslands); - if (grown !== null) { + // Packages compiled with runtime fences that need the JavaScript + // runtime or a mutable object model (fenceIslands): build again with + // them as islands, and keep this build when that one cannot compile. + // If that build fails, retry with only the packages whose fences fire + // for sure (module-init or runtime fences); if that fails too, the native + // build carries an SC8023 warning with the island build's first error, + // never a silent fallback. + if (fenceGrown !== null) { + const attempts = [fenceGrown]; + const sure = new Map( + [...fenceGrown].filter(([name]) => lowerIslands.has(name) || fenceSure.has(name)), + ); + if (sure.size > lowerIslands.size && sure.size < fenceGrown.size) attempts.push(sure); + let firstError: ScrDiagnostic | null = null; + for (const islands of attempts) { const islanded = prepareExecutableModule( entryPath, opts, @@ -218,10 +252,17 @@ export function prepareExecutableModule( buildPlatform, createFrontend, timing, - grown, + islands, false, ); if (islanded.ok) return islanded; + firstError ??= islanded.diagnostics[0] ?? null; + } + if (firstError !== null) { + const added = [...fenceGrown.keys()].filter((name) => !lowerIslands.has(name)); + prepared.warnings.push( + nodeIslandRetryFailedDiag(added, firstError, { file: entryPath, start: 0, end: 0 }), + ); } } return prepared; @@ -417,3 +458,71 @@ function preflightIslands( } return grown.size === known.size ? null : grown; } + +/** Packages compiled natively with runtime fences (refusals deferred to the + * operation that reaches them) that should run in islands instead: those + * whose island class is runtime (R1) or object-model (R2), by NI-2's report + * over the pre-scan markers and the fences, or with a fence that itself + * needs the runtime. Gap-only packages keep their fences until decision 8 + * (D6) retires them. `--node-islands=auto` only; null for none. */ +function fenceIslands( + entryPath: string, + opts: CompileRequestOptions, + fe: ReturnType, + lowered: LowerResult, + known: ReadonlyMap, + requireFence = true, + sure?: Set, +): Map | null { + if (fe.nodeIslandScan === undefined) return null; + if (requireFence && lowered.runtimeFences.length === 0) return null; + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + if (policy.mode !== "auto" || known.size > 32) return null; + // An explicit --npm-static request keeps its package native. + const explicit = new Set( + Array.isArray(opts.npmStatic) ? opts.npmStatic.map((n) => n.replace(/\.\.\.$/, "")) : [], + ); + const compiled = new Set( + fe.npmStatic + .filter((e) => e.status === "static" && !explicit.has(e.package)) + .map((e) => e.package), + ); + const fenced = new Map(); + for (const d of lowered.runtimeFences) { + const at = packageDirOf(d.loc.file); + if (at === null || !compiled.has(at.name) || known.has(at.name)) continue; + if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue; + // A fence in top-level module code throws when the module evaluates: + // it counts like one that needs the runtime. + const runtime = d.moduleInit === true || classifyDiagnostic(d, "fence").class === "runtime"; + fenced.set(at.name, (fenced.get(at.name) ?? false) || runtime); + if (runtime) sure?.add(at.name); + } + if (requireFence && fenced.size === 0) return null; + const report = buildNodeIslandReport({ + entry: entryPath, + scan: fe.nodeIslandScan({ userJs: false }), + npm: fe.npmStatic, + diagnostics: lowered.diagnostics, + fences: lowered.runtimeFences, + policy, + }); + const grown = new Map(known); + for (const island of report.islands) { + const runtimeFence = requireFence + ? fenced.get(island.package) + : (fenced.get(island.package) ?? false); + if (runtimeFence === undefined || island.path === null) continue; + if (!compiled.has(island.package) || known.has(island.package)) continue; + if (policy.forcedNative.has(island.package) || policy.unloaded.has(island.package)) continue; + if (!runtimeFence && island.class !== "runtime" && island.class !== "object-model") continue; + grown.set(island.package, { + name: island.package, + version: island.version, + dir: island.path, + class: island.class, + reason: island.reason, + }); + } + return grown.size === known.size ? null : grown; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 9ac451788..67ebb7f05 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4602,6 +4602,9 @@ function lowerArrayIsArrayCall( // itself instead of folding on the extracted type (typeof's rule). if (arg.kind === "dynCheck" && arg.value.type.kind === "dyn") arg = arg.value; if (arg.type.kind === "dyn") { + // With Node islands the value may be an island array (scope-B NI-4). + if (lowerer.nodeIslands !== null) + return { kind: "libCall", fn: "nodeIsland.isArray", args: [arg], type: BOOL, loc }; return { kind: "dynTest", test: "array", value: arg, type: BOOL, loc }; } if (arg.type.kind === "union") { @@ -4816,11 +4819,22 @@ function lowerUnionToStringCall( access: ts.PropertyAccessExpression, ): IrExpr | null { if (call.questionDotToken || access.questionDotToken) return null; - if (access.name.text !== "toString" || call.arguments.nodes.length !== 0) return null; + if (access.name.text !== "toString") return null; + // `.toString("utf8")` on `string | Buffer` (a Buffer's default decode; + // String.prototype.toString ignores its arguments): the same helper. + const args = call.arguments.nodes; + const utf8Arg = + args.length === 1 && ts.isStringLiteralLike(args[0]!) && /^utf-?8$/i.test(args[0].text); + if (args.length !== 0 && !utf8Arg) return null; const recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (recvT?.kind !== "union") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(recvT.unionId); + if ( + utf8Arg && + !def?.arms.every((a) => a.kind === "string" || (a.kind === "bytes" && a.elem === "u8")) + ) + return null; const stringable = def?.arms.every( (a) => a.kind === "string" || diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index b8e67d79e..817ee73c3 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,7 +1,7 @@ import { isIslandPromisifyCall, noteIslandSubclass } from "./lower-node-island.js"; import { isIslandSubclassDecl } from "../../node-islands/boundary.js"; import { THREADS_MODULE } from "../threads-import.js"; -import { collectIslandImport } from "./lower-node-island.js"; +import { collectIslandImport, collectIslandRequires } from "./lower-node-island.js"; import { NODE_API_MODULE } from "../node-api-import.js"; import { positionTag } from "./structural-positions.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; @@ -699,7 +699,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { } } } - for (const fp of parts) collectDynamicImports(lowerer, fp.sf); + for (const fp of parts) { + collectDynamicImports(lowerer, fp.sf); + collectIslandRequires(lowerer, fp.sf); + } } /** Literal dynamic-import collection: every diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index d192f2460..40fb46f8f 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -356,10 +356,32 @@ export function collectIslandImport( // module evaluated. const clause = ts.isImportDeclaration(stmt) ? stmt.importClause : undefined; const named = clause?.namedBindings; + const snapshotNames: string[] = []; if (!binding.esm && named !== undefined && ts.isNamedImports(named)) { for (const el of named.elements.nodes) { if (el.isTypeOnly) continue; - const name = el.propertyName?.text ?? el.name.text; + snapshotNames.push(el.propertyName?.text ?? el.name.text); + } + } + // A CommonJS namespace import's members are the same snapshot: the + // members the file reads (`ns.name`). + if (!binding.esm && named !== undefined && ts.isNamespaceImport(named)) { + const nsSymbol = lowerer.checker.getSymbolAtLocation(named.name); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + ts.isIdentifier(node.name) && + node.expression.text === named.name.text && + lowerer.checker.getSymbolAtLocation(node.expression) === nsSymbol && + !snapshotNames.includes(node.name.text) + ) + snapshotNames.push(node.name.text); + return undefined; + }); + } + { + for (const name of snapshotNames) { if (name === "default") continue; let g = binding.snapshots.get(name); if (g === undefined) { @@ -394,6 +416,7 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { } type IslandBinding = + | { kind: "required"; module: IslandModuleBinding } | { kind: "namespace"; module: IslandModuleBinding } | { kind: "default"; module: IslandModuleBinding } | { kind: "named"; module: IslandModuleBinding; name: string }; @@ -424,7 +447,13 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const name = decl.propertyName?.text ?? decl.name.text; binding = name === "default" ? "default" : { name }; } else { - return null; + // `const x = require("pkg")` (a CommonJS module of a native package + // requiring an island package): x is module.exports. + const spec = requireSpecOf(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); + return module === undefined ? null : { kind: "required", module }; } const spec = moduleSpecifierOf(importDecl); if (spec === null || importDecl === undefined) return null; @@ -451,6 +480,8 @@ function islandBindingValue( ): IrExpr { const ns = varRef(b.module.global.id, DYN, loc); switch (b.kind) { + case "required": + return ns; case "namespace": if (!b.module.esm) boundaryFail(lowerer, "the namespace object of a CommonJS island module as a value", node); @@ -530,6 +561,8 @@ function copiedType(lowerer: Lowerer, t: IrType): boolean { case "bigint": case "nullT": case "undefinedT": + // Buffers and typed arrays share their memory with the V8 view. + case "bytes": return true; case "union": { const def = lowerer.unions.get(t.unionId); @@ -939,6 +972,11 @@ function freshLiteral( node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression, ): IrExpr { const loc = locOf(node); + // Spreads, methods, accessors and computed keys: the literal lowers as + // the program's own object, which crosses by reference (NI-5 exports); + // nothing else holds it, so that is exact. + if (!plainLiteral(node)) + return lowerer.coerceToExpected(lowerer.lowerExprExpecting(node, DYN), DYN); if (ts.isArrayLiteralExpression(node)) { const elems = node.elements.nodes.map((el) => { if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) @@ -1433,6 +1471,8 @@ function islandReceiver( if (b !== null && b.kind === "namespace" && !b.module.esm) { const exports = varRef(b.module.global.id, DYN, loc); if (member === "default") return { value: exports, direct: exports }; + const snapshot = b.module.snapshots.get(member); + if (snapshot !== undefined) return { value: exports, direct: varRef(snapshot.id, DYN, loc) }; return { value: exports }; } } @@ -1761,6 +1801,72 @@ export function lowerIslandSuperMethodCall( return fromIsland(lowerer, raw, call); } +/** A literal freshLiteral copies member by member: identifier or string + * keys with values, shorthand properties, array elements without spreads + * or holes. */ +function plainLiteral(node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression): boolean { + if (ts.isArrayLiteralExpression(node)) + return node.elements.nodes.every( + (el) => !ts.isSpreadElement(el) && !ts.isOmittedExpression(el), + ); + return node.properties.nodes.every( + (p) => + (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) || + ts.isShorthandPropertyAssignment(p), + ); +} + +/** 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; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if ( + !ts.isCallExpression(init) || + !ts.isIdentifier(init.expression) || + init.expression.text !== "require" || + init.arguments.nodes.length !== 1 + ) + return null; + const arg = init.arguments.nodes[0]!; + return ts.isStringLiteralLike(arg) ? arg.text : 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). */ +export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void { + const islands = lowerer.nodeIslands; + if (islands === null) return; + for (const stmt of sf.statements.nodes) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations.nodes) { + const spec = requireSpecOf(decl); + if (spec === null) continue; + const path = islandModuleFile(islands, sf.fileName, spec, "require"); + if (path === null) continue; + lowerer.usesNodeIsland = true; + const loc = locOf(decl); + const binding = islandModuleGlobal(lowerer, path); + const actions = lowerer.jsonInitActions.get(sf) ?? []; + lowerer.jsonInitActions.set(sf, actions); + actions.push({ + kind: "assign", + localId: binding.global.id, + value: loadIslandModule(path, loc), + loc, + }); + } + } +} + /* `x instanceof Error` for values that may be island objects (NI-5c). */ const INTRINSIC_ERRORS = new Set([ "Error", diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index 4a63beb91..653526879 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -275,6 +275,11 @@ export function statefulReason(sf: ts.SourceFile): string | null { ) continue; if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives + // CommonJS export statements (`module.exports = f`, `exports.x = v`, + // `module.exports.x = v`) and expandos on the module's own functions + // (`f.sync = g`) declare the module's surface; they are not effects. + if (ts.isExpressionStatement(stmt) && isCjsDeclaringAssignment(stmt.expression, functions(sf))) + continue; return "its evaluation has effects beyond declarations"; } // Writes from function bodies to module-level bindings. @@ -527,3 +532,39 @@ export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] { }); return out; } + +/** The names of a module's top-level function declarations. */ +function functions(sf: ts.SourceFile): Set { + const names = new Set(); + for (const stmt of sf.statements.nodes) + if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) names.add(stmt.name.text); + return names; +} + +/** `module.exports = …`, `exports.x = …`, `module.exports.x = …` (also + * chained: `exports = module.exports = …`), or `f.x = …` for a top-level + * function `f`. */ +function isCjsDeclaringAssignment(e: ts.Expression, fns: Set): boolean { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return false; + const left = e.left; + const isModuleExports = (n: ts.Expression): boolean => + ts.isPropertyAccessExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "module" && + ts.isIdentifier(n.name) && + n.name.text === "exports"; + const target = + (ts.isIdentifier(left) && left.text === "exports") || + isModuleExports(left) || + (ts.isPropertyAccessExpression(left) && + ((ts.isIdentifier(left.expression) && + (left.expression.text === "exports" || fns.has(left.expression.text))) || + isModuleExports(left.expression))); + if (!target) return false; + // A chained assignment declares too. + const right = e.right; + if (ts.isBinaryExpression(right) && right.operatorToken.kind === ts.SyntaxKind.EqualsToken) + return isCjsDeclaringAssignment(right, fns); + return true; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 22be5cd06..7d97131e5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1031,12 +1031,48 @@ export function lowerToIr( ): LowerResult { setActiveNodeIslands(options.nodeIslands ?? null); try { - return lowerToIrWithIslands(program, entry, moduleOrder, options); + const result = lowerToIrWithIslands(program, entry, moduleOrder, options); + markModuleInitFences(program, result.runtimeFences); + return result; } finally { setActiveNodeIslands(null); } } +/** Marks the runtime fences in npm package code whose site is top-level + * module code (outside every function body): they throw when the module + * evaluates (ScrDiagnostic.moduleInit). */ +function markModuleInitFences(program: ts.Program, fences: readonly ScrDiagnostic[]): void { + for (const d of fences) { + if (!d.loc.file.includes("/node_modules/")) continue; + const sf = program.getSourceFile(d.loc.file); + if (sf === undefined) continue; + let inFunction = false; + let node: ts.Node | undefined = sf; + while (node !== undefined && !inFunction) { + let next: ts.Node | undefined; + ts.forEachChild(node, (child) => { + if ( + next === undefined && + ts.tokenStartOfNode(child) <= d.loc.start && + d.loc.start < child.end + ) + next = child; + return undefined; + }); + if (next === undefined) break; + if ( + ts.isFunctionLike(next) || + (ts.isPropertyDeclaration(next) && + !next.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) + ) + inFunction = true; + node = next; + } + if (!inFunction) d.moduleInit = true; + } +} + function lowerToIrWithIslands( program: ts.Program, entry: ts.SourceFile, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 37e944899..f22edc35b 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5505,7 +5505,11 @@ function preflight7(load: LoadResult): { // optional-dependency try/require pattern), so no fence. // Binding forms keep the fence: their downstream reads // would need the module that never loads. - if (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) { + if ( + (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) && + // A Node island package: loaded in Node at the require (NI-4). + !isFrontendIsland(packageNameOfSpecifier(req.spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(req.spec))); } } @@ -5562,7 +5566,10 @@ function preflight7(load: LoadResult): { if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) { // Binding-less by construction — same require-site throw // channel as the statement-level form above. - if (probeNodeRequireRefusal(sf.fileName, spec) === null) { + if ( + probeNodeRequireRefusal(sf.fileName, spec) === null && + !isFrontendIsland(packageNameOfSpecifier(spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec))); } } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 585f7798d..53333b9c2 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -201,6 +201,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "nodeIsland.apply": { argTypes: [DYN, DYN], result: DYN }, "nodeIsland.construct": { argTypes: [DYN, DYN], result: DYN }, "nodeIsland.instanceof": { argTypes: [DYN, DYN], result: BOOL }, + /** Array.isArray of a dyn value in a program with Node islands: island + * arrays answer true too. */ + "nodeIsland.isArray": { argTypes: [DYN], result: BOOL }, /** NI-5: a class instance crossing into an island, with the builder of * its class's method table (run once per class). Answers the value. */ "nodeIsland.classMethods": { argTypes: [DYN, DYN], result: DYN }, diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index 68967a619..d0b46c08c 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 = 8; +export const NODE_BRIDGE_ABI = 9; /** 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/6818-node-island-mixed/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json new file mode 100644 index 000000000..faf99c163 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6818-node-island-mixed/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json new file mode 100644 index 000000000..16d9cc12b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6819-node-island-fenced/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json new file mode 100644 index 000000000..e2367ec2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6825-node-island-init-fence/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json new file mode 100644 index 000000000..a864c36e1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6826-node-island-undefined-args/main.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index a2a708467..584325d50 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -1550,6 +1550,41 @@ Local ToV8(Isolate *isolate, Local context, const ScrNodeValue * } } +// Node's arrow header for an exception's throw site, recorded once on the +// error object (as Node decorates an error the first time it is thrown): +// ":\n\n", the caret under the +// message's columns with tabs kept. Printed by the runtime when the error +// ends the program uncaught (ErrorInfo's `arrow`). +Local ArrowKey(Isolate *isolate) { + return v8::Private::ForApi(isolate, Utf8(isolate, "scriptc.node-island.arrow")); +} + +void RecordArrow(Isolate *isolate, Local context, const TryCatch &try_catch, + Local exception) { + if (!exception->IsObject()) return; + Local error = exception.As(); + Local key = ArrowKey(isolate); + if (error->HasPrivate(context, key).FromMaybe(true)) return; + // Node decorates from the error's own message (its construction site, + // as Exception::CreateMessage answers for an Error with a stack), not + // from where the throw left the call. + Local message = v8::Exception::CreateMessage(isolate, exception); + if (message.IsEmpty()) message = try_catch.Message(); + if (message.IsEmpty()) return; + Local source; + if (!message->GetSourceLine(context).ToLocal(&source)) return; + std::string line = *String::Utf8Value(isolate, source); + std::string resource = *String::Utf8Value(isolate, message->GetScriptResourceName()); + int linenum = message->GetLineNumber(context).FromMaybe(0); + int start = message->GetStartColumn(context).FromMaybe(0); + int end = message->GetEndColumn(context).FromMaybe(start + 1); + std::string arrow = resource + ":" + std::to_string(linenum) + "\n" + line + "\n"; + for (int i = 0; i < start; i++) + arrow += (i < static_cast(line.size()) && line[i] == '\t') ? '\t' : ' '; + for (int i = start; i < end || i == start; i++) arrow += '^'; + (void)error->SetPrivate(context, key, Utf8(isolate, arrow.data(), arrow.size())); +} + // One entry from the runtime: the isolate scope, V8's stack limit for the // caller's stack, and the out-value scratch (reset by outermost entries). struct Op { @@ -1567,6 +1602,7 @@ struct Op { } if (try_catch.HasCaught() && !try_catch.HasTerminated()) { Local exception = try_catch.Exception(); + RecordArrow(e.isolate, e.context, try_catch, exception); try_catch.Reset(); FromV8(e.isolate, e.context, exception, out); } else { @@ -1828,6 +1864,9 @@ void ErrorInfo(uint32_t h, ScrNodeErrorInfo *out) { read("message", &out->message, &out->message_len, false); read("stack", &out->stack, &out->stack_len, true); read("code", &out->code, &out->code_len, true); + Local arrow; + if (error->GetPrivate(op.e.context, ArrowKey(op.e.isolate)).ToLocal(&arrow) && arrow->IsString()) + out->arrow = Scratch(Wtf8(op.e.isolate, arrow.As()), &out->arrow_len); } void DropHandle(uint32_t h) { diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6a6eab132..c15b707d6 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -60,7 +60,9 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_assert.c", any("assert", "regex", "symbol")], ["scr_inspect.c", "inspect"], ["scr_console_native.c", "inspect"], - ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch")], + // Island libCalls on native values typed by island declarations invoke + // through the checked-dynamic path (scr_node_island_handle.c). + ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch", "nodeIsland")], // Buffer.prototype methods called on checked-dynamic values. ["scr_bytes_dyn.c", all("dynBuffer", "dynInvoke")], // Builtin functions read as values in JavaScript dependencies. @@ -191,8 +193,13 @@ export function variantsFor(source) { defines: ["SCR_JS_TIER", "SCR_NODE_ISLANDS"], }); } - // util.inspect and instanceof of Node island handles. - if (source === "scr_inspect.c" || source === "scr_dyn_builtin_class.c") { + // util.inspect and instanceof of Node island handles; Node's report for + // an uncaught island error. + if ( + source === "scr_inspect.c" || + source === "scr_dyn_builtin_class.c" || + source === "scr_exception.c" + ) { variants.push({ id: "node-island", when: { nodeIsland: true }, diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index c7ff71349..bccb1b941 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -9,6 +9,9 @@ * dummy returns); this file only stores, renders, and releases the value. */ #include "scr_runtime.h" +#ifdef SCR_NODE_ISLANDS +#include "scr_node_island.h" +#endif #include #include @@ -684,6 +687,16 @@ void scr_exc_print_uncaught(void) { /* Settle any runtime-internal stdout fragment before the stderr line when * both share an fd. JavaScript-visible writes already flush themselves. */ fflush(stdout); +#ifdef SCR_NODE_ISLANDS + /* An error thrown by Node island code: Node's own report (the throw + * site's source line, the stack, the Node.js version). */ + if (scr_exc_kind == SCR_EXC_OBJ && scr_error_is(scr_exc_payload) && + scr_ni_report_uncaught((ScrError *)scr_exc_payload)) { + scr_exc_reset(); /* the payload counts as live until released */ + scr_exc_cell_changed(); + return; + } +#endif fputs("Uncaught ", stderr); switch (scr_exc_kind) { case SCR_EXC_F64: { diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d72038480..dcb459785 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3791,6 +3791,9 @@ static bool scr_native_iterator_over(const ScrDyn *value, unsigned kind); /* Node util.types reads internal brands, never user properties or prototypes. */ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (!value) return false; +#ifdef SCR_NODE_ISLANDS + if (scr_ni_is_handle(value)) return scr_ni_util_type_is(value, probe); +#endif #define PROBE(name) (probe->len == sizeof(name) - 1 && memcmp(probe->data, name, sizeof(name) - 1) == 0) if (PROBE("isAnyArrayBuffer")) return scr_buffer_storage_is(value); diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index 969477b58..830b67193 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 8u +#define SCR_NODE_BRIDGE_ABI 9u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -149,6 +149,11 @@ typedef struct ScrNodeErrorInfo { uint32_t kind; const char *name, *message, *stack, *code; size_t name_len, message_len, stack_len, code_len; + /* Node's arrow header for the throw site (":\n\n"), recorded when the error was thrown out of island + * code; NULL when none. */ + const char *arrow; + size_t arrow_len; } ScrNodeErrorInfo; /* What the runtime provides to the bridge. */ diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 99eab37eb..926128222 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -73,6 +73,13 @@ ScrDyn *scr_ni_call_method(ScrDyn *obj, const char *key, size_t len, ScrDyn *con size_t argc); ScrDyn *scr_ni_construct(ScrDyn *ctor, ScrDyn *const *args, size_t argc); ScrStr *scr_ni_typeof(ScrDyn *value); +/* util.types.(value) answered by Node for an island handle. */ +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe); +/* Array.isArray of a dyn value, island arrays included. */ +bool scr_ni_is_array(ScrDyn *value); +/* Prints Node's uncaught-exception report for an error thrown by island + * code (true), or nothing (false: not an island error). */ +bool scr_ni_report_uncaught(ScrError *error); int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_ni_is_handle(const ScrDyn *value); /* ToPrimitive of an island object (hint SCR_DYN_HINT_*), run by V8: @@ -172,6 +179,7 @@ ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args); ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args); void scr_node_island_define_field(ScrDyn *self, ScrStr *key, ScrDyn *value); ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key); +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key); bool scr_node_island_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_node_island_instanceof_global(ScrDyn *value, ScrStr *name); ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 3374bfe9d..4d03a4898 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1402,6 +1402,58 @@ void scr_ni_handles_install(void) { scr_atexit(nh_cleanup); } +/* ── NI-4d: util.types, Array.isArray and uncaught reports ─────────────── */ + +static ScrDyn *nh_util_types = NULL; /* Node's util.types (held to exit) */ + +static void nh_util_types_cleanup(void) { + scr_dyn_release(nh_util_types); + nh_util_types = NULL; +} + +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe) { + if (nh_util_types == NULL) { + ScrStr *path = scr_str_new("node:util", 9); + ScrDyn *util = scr_ni_require(path); + scr_str_release(path); + if (util == NULL) return false; + nh_util_types = scr_ni_get_str(util, "types", 5); + if (nh_util_types != NULL) scr_atexit(nh_util_types_cleanup); + scr_dyn_release(util); + if (nh_util_types == NULL) return false; + } + ScrDyn *args[1] = {(ScrDyn *)value}; + ScrDyn *r = scr_ni_call_method(nh_util_types, probe->data, probe->len, args, 1); + if (r == NULL) return false; + bool answer = r->kind == SCR_DYN_BOOL && r->v.b; + scr_dyn_release(r); + return answer; +} + +bool scr_ni_is_array(ScrDyn *value) { + if (value == NULL) return false; + if (value->kind == SCR_DYN_ARR) return true; + if (!scr_ni_is_handle(value)) return value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value); + return (((ScrNodeRef *)value->v.handle.ptr)->flags & SCR_NVF_ARRAY) != 0; +} + +bool scr_ni_report_uncaught(ScrError *error) { + ScrNodeRef *ref = nh_pinned(error, NH_PIN_ERROR); + const ScrNodeBridge *bridge = scr_ni_bridge_loaded(); + if (ref == NULL || ref->id == 0 || bridge == NULL) return false; + ScrNodeErrorInfo info; + bridge->error_info(ref->id, &info); + if (info.stack == NULL) return false; + if (info.arrow != NULL && info.arrow_len > 0) { + fwrite(info.arrow, 1, info.arrow_len, stderr); + fputs("\n\n", stderr); + } + fwrite(info.stack, 1, info.stack_len, stderr); + fprintf(stderr, "\n\nNode.js v%s\n", bridge->node_version); + fflush(stderr); + return true; +} + /* ── NI-4a: libCall entry points of compiled island code ───────────────── * The compiler (frontend/lowering/lower-node-island.ts) lowers member * operations on island values to `nodeIsland.*` libCalls, which pass @@ -1418,31 +1470,70 @@ static size_t ni_libcall_args(ScrDyn *args, ScrDyn *const **items) { return args->v.arr.len; } +/* Routing follows the value: a value typed by an island's declarations + * may still be a native object (an island interface annotating a native + * literal, a native error handed to an island callback). Those take the + * ordinary checked-dynamic operations instead of the bridge. */ +ScrDyn *scr_dyn_reflect_get(ScrDyn *target, ScrDyn *raw_key, ScrDyn *receiver); + +static bool ni_lc_island(const ScrDyn *v) { return v != NULL && scr_ni_is_handle(v); } + +/* A NUL-terminated copy of `key` for the dyn invoke path (heap only for long names). */ +static ScrDyn *ni_lc_native_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *const *items, size_t argc) { + char small[128]; + char *name = key->len < sizeof small ? small : malloc(key->len + 1); + if (name == NULL) scr_trap("scriptc: out of memory\n"); + memcpy(name, key->data, key->len); + name[key->len] = '\0'; + ScrDyn *result = scr_dyn_invoke(obj, name, items, argc, name); + if (name != small) free(name); + return result; +} + ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key) { - return scr_ni_get_str(obj, key->data, key->len); + if (ni_lc_island(obj)) return scr_ni_get_str(obj, key->data, key->len); + ScrDyn *k = scr_dyn_new_str(key); + ScrDyn *value = scr_dyn_reflect_get(obj, k, obj); + scr_dyn_release(k); + return value; +} + +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key) { + if (ni_lc_island(obj)) return scr_ni_get(obj, key); + return scr_dyn_reflect_get(obj, key, obj); } void scr_node_island_set(ScrDyn *obj, ScrStr *key, ScrDyn *value) { - (void)scr_ni_set_str(obj, key->data, key->len, value); + if (ni_lc_island(obj)) (void)scr_ni_set_str(obj, key->data, key->len, value); + else scr_dyn_key_set(obj, key, value); } void scr_node_island_set_key(ScrDyn *obj, ScrDyn *key, ScrDyn *value) { - (void)scr_ni_set(obj, key, value); + if (ni_lc_island(obj)) (void)scr_ni_set(obj, key, value); + else scr_dyn_key_set_computed(obj, key, value); } ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call_method(obj, key->data, key->len, items, argc); + if (ni_lc_island(obj)) return scr_ni_call_method(obj, key->data, key->len, items, argc); + return ni_lc_native_invoke(obj, key, items, argc); } ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call(fn, NULL, items, argc); + if (ni_lc_island(fn)) return scr_ni_call(fn, NULL, items, argc); + return scr_dyn_call(fn, items, argc, "value"); } ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args) { + if (!ni_lc_island(ctor)) { + ScrStr *what = scr_str_new("value", 5); + ScrDyn *result = scr_dyn_construct(ctor, args, what); + scr_str_release(what); + return result; + } ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); return scr_ni_construct(ctor, items, argc); diff --git a/tests/corpus/6331-jose-root-entry/package.json b/tests/corpus/6331-jose-root-entry/package.json new file mode 100644 index 000000000..30d3e0af0 --- /dev/null +++ b/tests/corpus/6331-jose-root-entry/package.json @@ -0,0 +1 @@ +{ "type": "module", "scriptc": { "packages": { "jose": "native" } } } diff --git a/tests/corpus/6610-npm-execa-child-mixin/package.json b/tests/corpus/6610-npm-execa-child-mixin/package.json new file mode 100644 index 000000000..5a418a30c --- /dev/null +++ b/tests/corpus/6610-npm-execa-child-mixin/package.json @@ -0,0 +1 @@ +{ "type": "module", "scriptc": { "packages": { "run-node": "native" } } } diff --git a/tests/corpus/6818-node-island-mixed/main.ts b/tests/corpus/6818-node-island-mixed/main.ts new file mode 100644 index 000000000..4c1e05d76 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/main.ts @@ -0,0 +1,49 @@ +// @requires-libnode +// A Node island package used from both sides: a natively compiled package +// requires it; island-declared interfaces type native objects too (member +// operations follow the value, not the declared type); options built with +// spread and method members cross in by reference; a `string | Buffer` +// member reads back from an island object; a rejection's error is read in +// a native callback. +import { twice } from "wraplib"; +import { + countFiles, + describeFile, + blob, + text, + render, + failLater, + type Files, + type File, + type FailError, +} from "islandlib"; + +console.log("twice", twice(5)); + +const files: Files = {}; +files["a.txt"] = { mode: 420, data: "alpha" }; +files["b.txt"] = blob("beta"); +console.log("files", countFiles(files), files["a.txt"]!.mode, Object.keys(files).join(",")); +const local: File = files["a.txt"]!; +console.log("local", local.mode, describeFile(local), describeFile(files["b.txt"]!)); + +const fromIsland = blob("gamma"); +const data = fromIsland.data; +console.log("buffer", typeof data === "string" ? data : data.toString("utf8"), describeFile(text("delta"))); + +const base = { name: "job", retries: 1 }; +console.log( + "render", + render({ + ...base, + retries: 3, + format(name: string): string { + return name.toUpperCase(); + }, + }), +); + +failLater("nope").catch((e: unknown) => { + const err = e as FailError; + console.log("caught", err.message, err.code, (e as { message?: string }).message); +}); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts new file mode 100644 index 000000000..1940c427d --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts @@ -0,0 +1,27 @@ +export declare class Counter { + constructor(start: number); + value: number; + inc(): number; +} +export interface File { + mode: number; + data: string | Buffer; +} +export interface Files { + [path: string]: File; +} +export interface RenderOptions { + name: string; + retries: number; + format(name: string): string; +} +export interface FailError extends Error { + code: string; +} +export declare function makeCounter(start: number): Counter; +export declare function countFiles(files: Files): number; +export declare function describeFile(file: File): string; +export declare function blob(text: string): File; +export declare function text(text: string): File; +export declare function render(opts: RenderOptions): string; +export declare function failLater(message: string): Promise; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js new file mode 100644 index 000000000..a9ec7e11d --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js @@ -0,0 +1,17 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. +const { createRequire } = require("node:module"); +const load = createRequire(__filename); +const { Counter } = load(["./lib", "counter"].join("/")); + +exports.makeCounter = (start) => new Counter(start); +exports.Counter = Counter; +// A files map: native callers build their own with the same interface. +exports.countFiles = (files) => Object.keys(files).length; +exports.describeFile = (file) => `${file.mode}:${file.data.toString("utf8")}`; +exports.blob = (text) => ({ mode: 420, data: Buffer.from(text) }); +exports.text = (text) => ({ mode: 384, data: text }); +// Takes options built from spread and method members. +exports.render = (opts) => `${opts.name}/${opts.retries}/${opts.format(opts.name)}`; +// Settles a promise with an Error the caller inspects in .catch. +exports.failLater = (message) => Promise.reject(Object.assign(new Error(message), { code: "EFAIL" })); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js new file mode 100644 index 000000000..def5f80e4 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js @@ -0,0 +1,10 @@ +"use strict"; +class Counter { + constructor(start) { + this.value = start; + } + inc() { + return ++this.value; + } +} +exports.Counter = Counter; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json new file mode 100644 index 000000000..b3a5a4e8b --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json @@ -0,0 +1 @@ +{ "name": "islandlib", "version": "2.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts new file mode 100644 index 000000000..225a4106b --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts @@ -0,0 +1 @@ +export declare function twice(start: number): number; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js new file mode 100644 index 000000000..6c010494f --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js @@ -0,0 +1,9 @@ +"use strict"; +// A plain package scriptc compiles natively that requires an island package. +const islandlib = require("islandlib"); + +exports.twice = function twice(start) { + const c = islandlib.makeCounter(start); + c.inc(); + return c.inc(); +}; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json new file mode 100644 index 000000000..5679ba18c --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json @@ -0,0 +1 @@ +{ "name": "wraplib", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "islandlib": "2.0.0" } } diff --git a/tests/corpus/6819-node-island-fenced/main.ts b/tests/corpus/6819-node-island-fenced/main.ts new file mode 100644 index 000000000..7416c5ce4 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/main.ts @@ -0,0 +1,10 @@ +// @requires-libnode +// A package scriptc compiles natively only with runtime fences that need +// a mutable object model (getters over live bindings, a constructor +// function with prototype methods) runs in a Node island automatically. +import { bump, current, Tally } from "fencedkit"; +import kit from "fencedkit"; + +console.log(current, bump(), bump(), kit.current); +const t = new Tally(1); +console.log(t.add(2) === t, t.add(3).total, t instanceof Tally); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts new file mode 100644 index 000000000..817efabc9 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts @@ -0,0 +1,7 @@ +export declare const current: number; +export declare function bump(): number; +export declare class Tally { + constructor(start: number); + total: number; + add(n: number): Tally; +} diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js new file mode 100644 index 000000000..a6dac5623 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js @@ -0,0 +1,29 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings, +// plus a constructor function with prototype methods (R2.1). scriptc +// compiles it natively only with runtime fences, so it runs in a Node +// island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Tally: () => Tally, + current: () => current, + bump: () => bump, +}); +module.exports = src_exports; +let current = 0; +function bump() { + current += 1; + return current; +} +function Tally(start) { + this.total = start; +} +Tally.prototype.add = function (n) { + this.total += n; + return this; +}; +0 && (module.exports = { Tally, bump, current }); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json new file mode 100644 index 000000000..51fc5639c --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json @@ -0,0 +1 @@ +{ "name": "fencedkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6825-node-island-init-fence/main.ts b/tests/corpus/6825-node-island-init-fence/main.ts new file mode 100644 index 000000000..7e5ba8391 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/main.ts @@ -0,0 +1,16 @@ +// @requires-libnode +// A package whose top-level code always hits a runtime fence (pump's +// `var` function exported by reference), reached through a natively +// compiled package, runs in a Node island automatically; a `string | +// Buffer` member read from an island object decodes with toString("utf8"); +// a CommonJS helper required on both sides is duplicated quietly (its +// exports are declarations). +import { pack } from "tarlike"; +import { fromText, fromBytes, onceTotal } from "bloblike"; + +console.log("pack", pack([1, 2, 3])); +for (const blob of [fromText("plain"), fromBytes("bytes")]) { + const data = blob.data; + console.log("blob", blob.mode.toString(8), data.toString("utf8"), typeof data === "string"); +} +console.log("once", onceTotal([4, 5])); diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts new file mode 100644 index 000000000..3858e4a0c --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts @@ -0,0 +1,8 @@ +export declare class FileBlob { + constructor(data: string | Buffer); + data: string | Buffer; + mode: number; +} +export declare function fromText(text: string): FileBlob; +export declare function fromBytes(text: string): FileBlob; +export declare function onceTotal(values: number[]): number; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js new file mode 100644 index 000000000..31ccad123 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js @@ -0,0 +1,19 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. It +// also requires the CommonJS helper the native side uses. +const { createRequire } = require("node:module"); +const once = createRequire(__filename)(["once", "like"].join("")); + +class FileBlob { + constructor(data) { + this.data = data; + this.mode = 0o644; + } +} +exports.FileBlob = FileBlob; +exports.fromText = (text) => new FileBlob(text); +exports.fromBytes = (text) => new FileBlob(Buffer.from(text)); +exports.onceTotal = (values) => { + const sum = once(() => values.reduce((a, b) => a + b, 0)); + return sum() + sum(); +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json new file mode 100644 index 000000000..b9db808e4 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json @@ -0,0 +1 @@ +{ "name": "bloblike", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js new file mode 100644 index 000000000..b1f19e69a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js @@ -0,0 +1,17 @@ +// A CommonJS helper both native code and an island require: its exports +// are declarations, so each side's copy is unobservable. +module.exports = once; +once.strict = onceStrict; +function once(fn) { + var f = function () { + if (f.called) return f.value; + f.called = true; + return (f.value = fn.apply(this, arguments)); + }; + f.called = false; + return f; +} +function onceStrict(fn) { + var f = once(fn); + return f; +} diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json new file mode 100644 index 000000000..852d69c5a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json @@ -0,0 +1 @@ +{ "name": "oncelike", "version": "1.4.0", "main": "once.js" } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js new file mode 100644 index 000000000..01bf4da9e --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js @@ -0,0 +1,17 @@ +// pump's shape: a function stored in a module-level `var` and exported by +// reference after it is defined. +var once = require("oncelike"); + +var noop = function () {}; + +var pumpish = function () { + var steps = Array.prototype.slice.call(arguments); + var done = once(typeof steps[steps.length - 1] === "function" ? steps.pop() : noop); + var total = steps.reduce(function (acc, n) { + return acc + n; + }, 0); + done(total); + return total; +}; + +module.exports = pumpish; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json new file mode 100644 index 000000000..40b2cc4e2 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json @@ -0,0 +1 @@ +{ "name": "pumplike", "version": "3.0.3", "main": "index.js", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts new file mode 100644 index 000000000..9de17307a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts @@ -0,0 +1 @@ +export declare function pack(sizes: number[]): string; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js new file mode 100644 index 000000000..22eb47020 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js @@ -0,0 +1,13 @@ +"use strict"; +// A typed package scriptc compiles natively that requires pump's shape. +const pump = require("pumplike"); +const once = require("oncelike"); + +exports.pack = function pack(sizes) { + let reported = 0; + const report = once((n) => { + reported = n; + }); + const total = pump(...sizes, report); + return `${total}/${reported}`; +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json new file mode 100644 index 000000000..877fe5f27 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json @@ -0,0 +1 @@ +{ "name": "tarlike", "version": "2.1.5", "main": "index.js", "types": "index.d.ts", "dependencies": { "pumplike": "3.0.3", "oncelike": "1.4.0" } } diff --git a/tests/corpus/6826-node-island-undefined-args/main.ts b/tests/corpus/6826-node-island-undefined-args/main.ts new file mode 100644 index 000000000..94a350e67 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/main.ts @@ -0,0 +1,82 @@ +// @requires-libnode +// Arguments crossing into a Node island keep JavaScript's exact shape: +// explicit `undefined` arguments count (arguments.length, default +// parameters), an omitted optional field is absent while one holding +// `undefined` is present (`in`, hasOwnProperty, Object.keys), and native +// number arrays arrive as arrays. +import { + probe, + defaults, + version, + inspect, + nodeVersionFor, + cwd, + envValue, + type Config, +} from "argprobe"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface Settings { + nodeVersion?: string; +} +function settingsFor(flag: boolean): Settings | undefined { + return flag ? { nodeVersion: "20.x" } : undefined; +} + +console.log(probe()); +console.log(probe(undefined)); +console.log(probe("a", undefined)); +console.log(probe("a", {}, undefined)); +console.log(probe("a", { nodeVersion: undefined }, undefined, [18, 20, 22])); +const settings = settingsFor(false); +console.log(probe("a", { nodeVersion: settings?.nodeVersion }, undefined, [22])); +console.log(probe("a", { label: "x", nodeVersion: undefined })); + +console.log(defaults()); +console.log(defaults(undefined)); +console.log(defaults(undefined, "y")); +console.log(defaults("x", undefined, "r")); + +const available: number[] = [18, 20, 22]; +console.log(version("/work", "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: settingsFor(true)?.nodeVersion }, undefined, available)); +console.log(version("/work", undefined, undefined, undefined, available)); +console.log(inspect({ nodeVersion: undefined, list: [1, undefined, 3] })); + +// A native object typed by the island's own interface, read with `?.`. +function projectSettings(flag: boolean): Config | undefined { + return flag ? { nodeVersion: "20.x", label: "proj" } : undefined; +} +const project = projectSettings(true); +const none = projectSettings(false); +console.log(version("/work", "unused", { nodeVersion: project?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: none?.nodeVersion }, undefined, available)); +console.log(probe("p", { nodeVersion: project?.nodeVersion }), probe("n", { nodeVersion: none?.nodeVersion })); + +async function main(): Promise { + // A package.json the program writes just before the island reads it. + const dir = mkdtempSync(join(tmpdir(), "argprobe-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ engines: { node: "22.x" } })); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); + rmSync(join(dir, "package.json")); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: "18.x" }, { isDev: true })); + try { + await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available); + } catch (e) { + console.log("rejected", (e as Error).message); + } + // Process state the program changes after Node loaded is Node's too. + const before = process.cwd(); + process.chdir(dir); + console.log("cwd", cwd() === process.cwd(), cwd() === before); + process.chdir(before); + process.env["ARGPROBE_SETTING"] = "set-natively"; + console.log("env", envValue("ARGPROBE_SETTING")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} +void main(); diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts new file mode 100644 index 000000000..72cde09b4 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts @@ -0,0 +1,23 @@ +export interface Config { + nodeVersion?: string; + label?: string; +} +export declare function probe(a?: unknown, b?: Config, c?: unknown, d?: number[]): string; +export declare function defaults(x?: string, y?: string, ...rest: string[]): string; +export declare function version( + path: string, + fallback?: string, + config?: Config, + meta?: { nodeVersion?: string }, + available?: number[], +): string; +export declare function inspect(v: unknown): string; +export declare function nodeVersionFor( + dir: string, + fallback?: string, + config?: Config, + meta?: { isDev?: boolean }, + available?: number[], +): Promise; +export declare function cwd(): string; +export declare function envValue(name: string): string | undefined; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js new file mode 100644 index 000000000..5a9e4245e --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js @@ -0,0 +1,70 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. Its +// functions report exactly what arrives: argument counts, explicit +// undefined, key presence of undefined-valued fields, arrays. +const { createRequire } = require("node:module"); +const util = createRequire(__filename)(["node", "util"].join(":")); + +function describeValue(v) { + if (v === undefined) return "undefined"; + if (Array.isArray(v)) return `array(${v.length}:${v.map(describeValue).join(",")})`; + if (v !== null && typeof v === "object") + return `{${Object.keys(v) + .map((k) => `${k}=${describeValue(v[k])}`) + .join(",")}}`; + return JSON.stringify(v); +} + +exports.probe = function probe(a, b, c, d) { + return [ + `len=${arguments.length}`, + `a=${describeValue(a)}`, + `b=${describeValue(b)}`, + `in=${b !== undefined && b !== null && "nodeVersion" in b}`, + `own=${b !== undefined && b !== null && Object.prototype.hasOwnProperty.call(b, "nodeVersion")}`, + `c=${describeValue(c)}`, + `d=${describeValue(d)}`, + ].join(" "); +}; + +exports.defaults = function defaults(x = "dx", y = "dy", ...rest) { + return `${x}|${y}|rest=${rest.length}|len=${arguments.length}`; +}; + +exports.version = function version(path, fallback = "unused", config = {}, meta, available) { + const wanted = config.nodeVersion ?? (meta && meta.nodeVersion) ?? null; + const list = Array.isArray(available) ? available : []; + if (wanted === null) { + const latest = Math.max(...list); + return `${path}: ${list.includes(latest) ? `${latest}.x` : fallback}`; + } + return `${path}: ${wanted}`; +}; + +exports.inspect = (v) => util.inspect(v); + +// A version-lookup helper's shape: async, defaults for omitted +// arguments, engines.node from the package.json in `dir` first. +exports.nodeVersionFor = async function nodeVersionFor( + dir, + fallback = "unused", + config = {}, + meta = {}, + available = [16], +) { + const fs = require("node:fs/promises"); + const path = require("node:path"); + let engines = null; + try { + engines = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")).engines ?? null; + } catch { + engines = null; + } + if (engines && engines.node) return `engines:${engines.node}:${Math.max(...available)}`; + const configured = config.nodeVersion || fallback; + if (configured === "unused") throw new Error(`Found invalid or discontinued Node.js Version: "${configured}"`); + return `configured:${configured}:${meta.isDev === true}`; +}; + +exports.cwd = () => process.cwd(); +exports.envValue = (name) => process.env[name]; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json new file mode 100644 index 000000000..4b7cb717e --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json @@ -0,0 +1 @@ +{ "name": "argprobe", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 1f6b525cb..f205e8f68 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 8)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 9)\n`, ); }); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 7533693cc..d08a4c0fb 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -45,20 +45,20 @@ const KNOWN: Record = { "any-chaining": { build: "SC2003" }, "assert-shims": { run: "SC1043" }, "buffer-shims": { run: "SC2020" }, - "bytes-exit": { build: "SC2004" }, + "bytes-exit": { island: true }, "cjs-esm-named": { island: true }, "cjs-lexer-aligned": { island: true }, "commander-calc": { run: "SC1090", argvs: [["--help"]] }, "commander-calc-async": { run: "SC1090", argvs: [["--help"]] }, "create-require": { run: "SC1090" }, - "dotfolder": { run: "SC1090" }, + "dotfolder": { island: true }, "dual-package-kinds": { island: true }, "dynamic-import": { island: true }, "dynamic-import-local": { build: "SC2012" }, "esbuild-require": { island: true }, "esm-missing-default": { build: "SC1090" }, "esm-missing-export": { build: "SC1090" }, - "fetch-destructure": { run: "SC1031" }, + "fetch-destructure": { island: true }, "fs-shims": { run: "SC1101" }, "import-refusal": { build: "SC2020" }, "island-registry": { build: "SC2011" }, @@ -66,42 +66,46 @@ const KNOWN: Record = { divergence: "Timeout handles are numbers (typeof) and lose their ref flag once fired or cleared", }, + "hot-shots-udp": { + divergence: + "as a Node island (its runtime fences pick one), output stops after the socket-not-running error; the built-in hot-shots shim (#1836) compiles it natively", + }, "island-web-plumbing": { run: "SC2020" }, "lazy-traps": { island: true }, - "misc-shims": { run: "SC2020" }, + "misc-shims": { island: true }, namespace: { island: true }, "node24-async-events": { run: "SC2020" }, "node24-commonjs-metadata": { island: true }, "node24-crypto-kdf": { run: "SC2020" }, - "node24-deep-equality": { run: "SC2020" }, + "node24-deep-equality": { island: true }, "node24-diagnostics-async": { run: "SC1090" }, "node24-diagnostics-tracing": { run: "SC1090" }, - "node24-env": { run: "SC1090" }, - "node24-esm-metadata": { run: "SC1090" }, + "node24-env": { island: true }, + "node24-esm-metadata": { island: true }, "node24-event-emitter": { run: "SC2020" }, "node24-file-url-conversion": { island: true }, "node24-fs-descriptors": { run: "SC2020" }, - "node24-mime": { run: "SC2020" }, - "node24-mime-errors": { run: "SC1090" }, + "node24-mime": { island: true }, + "node24-mime-errors": { island: true }, "node24-no-error-assertions": { run: "SC2020" }, - "node24-parseargs": { run: "SC1090" }, + "node24-parseargs": { island: true }, "node24-promise-timers": { run: "SC2020" }, "node24-string-decoder": { run: "SC2020" }, - "node24-style-text": { run: "SC2020" }, + "node24-style-text": { island: true }, "node24-system-errors": { run: "SC1090" }, "node24-text-encoding": { run: "SC2020" }, "node24-text-utilities": { run: "SC2020" }, "node24-timers-unref": { run: "SC2020" }, "node24-url-factories": { run: "SC2020" }, "node24-url-mutation": { run: "SC1090" }, - "node24-util-adapters": { run: "Object.create with this value is not supported yet" }, - "node24-util-types": { run: "SC1090" }, + "node24-util-adapters": { island: true }, + "node24-util-types": { island: true }, "node24-web-stream-encoding": { run: "SC1090" }, "node24-web-stream-errors": { run: "SC1090" }, "node24-web-stream-lifecycle": { run: "SC1090" }, "node24-web-stream-pipe-abort": { run: "SC2020" }, "node24-web-stream-pipe-errors": { run: "SC1090" }, - "node24-web-stream-pipes": { run: "SC1090" }, + "node24-web-stream-pipes": { island: true }, "node24-web-stream-queues": { run: "SC1090" }, "node24-web-stream-readable-lifecycle": { run: "SC2020" }, "node24-web-stream-readers": { run: "SC1090" }, @@ -110,8 +114,8 @@ const KNOWN: Record = { "package-imports": { island: true }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { run: "SC1090" }, - "util-shims": { run: "SC2020" }, + "stream-shims": { island: true }, + "util-shims": { island: true }, "web-streams": { run: "SC1090" }, "workspace-copied": { island: true }, "workspace-unclean": { build: "SC2013" }, @@ -189,7 +193,45 @@ async function build(entry: string): Promise { }; } -async function expectMatchesNode(c: NpmCase, binary: string): Promise { +/** The two stderr texts with their stack frame runs cut to the frames + * they share: an uncaught error from a Node island prints Node's report, + * whose frames match Node's from the throw site up to the first native + * frame, which has no Node-identical spelling (DESIGN.md §2.10). Every + * other line, and each run's common leading frames, compare exactly. */ + +/** Node's uncaught-error report ends with `Node.js v`: the island + * prints libnode's release, which may differ from the reference Node's + * patch release. */ +function normalizeNodeVersion([a, b]: [string, string]): [string, string] { + const re = /^Node\.js v\d+\.\d+\.\d+$/m; + return [a.replace(re, "Node.js v"), b.replace(re, "Node.js v")]; +} + +function sharedFrames(native: Buffer, node: Buffer): [string, string] { + const frame = (line: string): boolean => /^ {4}at /.test(line); + const a = native.toString("utf8").split("\n"); + const b = node.toString("utf8").split("\n"); + const outA: string[] = []; + const outB: string[] = []; + let i = 0; + let j = 0; + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && frame(a[i]!) && frame(b[j]!)) { + while (i < a.length && j < b.length && frame(a[i]!) && a[i] === b[j]) { + outA.push(a[i++]!); + outB.push(b[j++]!); + } + while (i < a.length && frame(a[i]!)) i++; + while (j < b.length && frame(b[j]!)) j++; + continue; + } + if (i < a.length) outA.push(a[i++]!); + if (j < b.length) outB.push(b[j++]!); + } + return [outA.join("\n"), outB.join("\n")]; +} + +async function expectMatchesNode(c: NpmCase, binary: string, islandFrames = false): Promise { const cwd = dirname(c.entry); for (const argv of c.argvs ?? [[]]) { const [nodeRes, nativeRes] = await Promise.all([ @@ -202,7 +244,13 @@ async function expectMatchesNode(c: NpmCase, binary: string): Promise { expect.unreachable("stdout differed at byte level but not after utf8 decode"); } expect(nativeRes.exitCode, label).toBe(nodeRes.exitCode); - if (c.compareStderr) expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); + if (c.compareStderr && islandFrames) { + const [native, node] = normalizeNodeVersion( + sharedFrames(programStderr(nativeRes.stderr), nodeRes.stderr), + ); + expect(native, label).toBe(node); + } else if (c.compareStderr) + expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); } } @@ -231,7 +279,7 @@ describe(`npm native (${cases.length} programs${sanitize ? ", sanitized" : ""}${ return; } if (!built.ok) throw new Error(`npm fixture failed to compile:\n${built.text}`); - await expectMatchesNode(c, built.binary); + await expectMatchesNode(c, built.binary, true); }, 120_000); continue; } From 55a69232b765ea4470dd111a9502478e54c11349 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:55 -0700 Subject: [PATCH 02/47] Exit cleanly with Node loaded and keep nested entries on Node's stack (NI-7c, NI-3b) Node islands stack, part 8 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../main.ts.json | 6 + .../libnode-common/bridge/scr_node_bridge.cc | 69 ++++++++++- packages/runtime/src/scr_async.c | 20 ++++ packages/runtime/src/scr_node_island.c | 108 +++++++++++++++++- packages/runtime/src/scr_node_island.h | 5 + packages/runtime/src/scr_node_island_handle.c | 4 + .../6870-node-island-server-fibers/main.ts | 91 +++++++++++++++ .../node_modules/server-kit/index.d.ts | 14 +++ .../node_modules/server-kit/index.js | 53 +++++++++ .../node_modules/server-kit/package.json | 1 + .../package.json | 1 + tests/fixtures/node-island/exit-tls.cjs | 16 +++ tests/fixtures/node-island/exit-tls.mjs | 20 ++++ tests/harness/lsan.supp | 21 ++++ tests/harness/node-island-exit.test.ts | 87 ++++++++++++++ 15 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json create mode 100644 tests/corpus/6870-node-island-server-fibers/main.ts create mode 100644 tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts create mode 100644 tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js create mode 100644 tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json create mode 100644 tests/corpus/6870-node-island-server-fibers/package.json create mode 100644 tests/fixtures/node-island/exit-tls.cjs create mode 100644 tests/fixtures/node-island/exit-tls.mjs create mode 100644 tests/harness/node-island-exit.test.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json new file mode 100644 index 000000000..0d9124d48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6870-node-island-server-fibers/main.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 584325d50..d63a3b6f1 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -183,6 +183,26 @@ bool TakeFunction(Isolate *isolate, Local context, Local from, return true; } +// OpenSSL registers OPENSSL_cleanup with atexit on its first +// initialization. In a scriptc program that loaded Node, threads Node owns +// can still be inside OpenSSL when the process's exit handlers run: the +// threads node:tls starts to parse the root certificates (long with +// NODE_USE_SYSTEM_CA=1; RunLoop waits for them, but an exit that does not +// end the loop does not) are joined only by Node's per-process teardown, +// which cannot run here (see Stop). OPENSSL_cleanup +// freed OpenSSL's locks and method stores under them (SIGSEGV/SIGABRT at +// exit in up to half the runs, NI-7c). Initializing OpenSSL first with +// OPENSSL_INIT_NO_ATEXIT keeps that handler from being registered: OpenSSL's +// process-lifetime state is left to process exit, like the rest of Node's +// per-process state. Packs whose libnode does not export the symbol keep +// OpenSSL's default. +void KeepOpenSSLAtExit() { + using InitCrypto = int (*)(uint64_t, const void *); + constexpr uint64_t kNoAtExit = 0x00080000L; // OPENSSL_INIT_NO_ATEXIT (OpenSSL 3) + auto init = reinterpret_cast(dlsym(RTLD_DEFAULT, "OPENSSL_init_crypto")); + if (init != nullptr) init(kNoAtExit, nullptr); +} + char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *argv0, uintptr_t stack_limit) { if (g_setup) return nullptr; @@ -192,6 +212,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar Dl_info info; if (dladdr(reinterpret_cast(&napi_create_function), &info) && info.dli_fname) dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD | RTLD_GLOBAL); + KeepOpenSSLAtExit(); std::vector args = {argv0}; auto result = node::InitializeOncePerProcess( args, {node::ProcessInitializationFlags::kNoInitializeV8, @@ -390,10 +411,18 @@ void OnCap(uv_timer_t *) { SyncIo(SCR_NODE_IO_JOBS); } -void OnPrepare(uv_prepare_t *) { SyncIo(SCR_NODE_IO_PREPARE); } +// Before each poll phase, while the runtime has live work: one pass over +// its dispatch stations, as the runtime's own loop makes one per turn. +// Some of their work no fd announces (a server's deferred 'listening' or +// 'close', a socket's queued writes), so waiting for an fd could block the +// loop forever with that work pending. +void OnPrepare(uv_prepare_t *handle) { + if (uv_has_ref(reinterpret_cast(handle))) Dispatch(); + SyncIo(SCR_NODE_IO_PREPARE); +} // The end of an iteration: the next one may dispatch again (from its idle -// phase when work is ready, else from its poll phase). +// or prepare phase, else from its poll phase). void OnCheck(uv_check_t *) { g_dispatched = false; SyncIo(SCR_NODE_IO_CHECK); @@ -573,6 +602,29 @@ void Checkpoint(int microtasks_first) { node::CallbackScope scope(e.isolate, Object::New(e.isolate), node::async_context{0, 0}); } +// Loading node:tls starts threads that parse the root certificates into +// function-local statics (bundled, NODE_EXTRA_CA_CERTS, and the system +// store with NODE_USE_SYSTEM_CA=1, which takes long). Only Node's +// CleanupCachedRootCertificates joins them. Once exit() begins, the +// statics' destructors (registered when the threads initialize them, so +// they run before any handler the program registered earlier) free the +// vectors under a thread that is still parsing, and OpenSSL's state goes +// away under it: heap corruption and SIGSEGV/SIGABRT at exit (NI-7c). So +// when the loop is done, before the program returns, the bridge waits for +// them: building a root store reads the same statics under the same +// conditions, and initializing a static blocks until the thread that runs +// it is done. A store built earlier means the statics were initialized +// then. Programs that never loaded node:tls do nothing here. +void AwaitCertificateLoading(Enter &e) { + TryCatch try_catch(e.isolate); + static const char kSource[] = + "(() => { try { if (process.moduleLoadList.includes('NativeModule tls'))" + " process.getBuiltinModule('node:tls').createSecureContext(); } catch {} })()"; + Local script; + if (v8::Script::Compile(e.context, Utf8(e.isolate, kSource)).ToLocal(&script)) + (void)script->Run(e.context); +} + int RunLoop(int continue_turn) { Enter e; // The rest of the turn the switch interrupted: libuv's UV_RUN_ONCE skips @@ -581,7 +633,9 @@ int RunLoop(int continue_turn) { uv_run(g_setup->event_loop(), UV_RUN_ONCE); g_platform->DrainTasks(e.isolate); } - return node::SpinEventLoop(g_setup->env()).FromMaybe(0); + int code = node::SpinEventLoop(g_setup->env()).FromMaybe(0); + AwaitCertificateLoading(e); + return code; } // An island listener of a process event: through MakeCallback, so a throw @@ -610,9 +664,16 @@ void Stop() { delete g_locker; g_locker = nullptr; g_setup.reset(); + // As Node's own exit does (DefaultProcessExitHandlerInternal): join + // libuv's thread pool, whose work (pbkdf2, key generation, ...) may be + // inside OpenSSL, before the process's exit handlers clean OpenSSL up. + uv_library_shutdown(); v8::V8::Dispose(); v8::V8::DisposePlatform(); - node::TearDownOncePerProcess(); + // Node's per-process teardown is left to process exit: Stop runs from an + // exit handler registered when Node started, after the destructors of + // the certificate statics (registered later) already ran, so its + // certificate cleanup would free them a second time. } diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 816ad8f7d..921c73648 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1598,9 +1598,26 @@ static void *scr_win_self(void) { } #endif +#ifdef SCR_NODE_ISLANDS +/* The lowest frame of Node's stack that is still live while a fiber runs: + * where runtime code on Node's stack (a job or host callback, however deep + * it went) last switched to a fiber. A call into Node from that fiber runs + * on Node's stack below it (scr_node_island.c), never over those frames. + * 0: none. scr_node_stack_has (set when Node starts) tells Node's stack. */ +uintptr_t scr_node_stack_low = 0; +bool (*scr_node_stack_has)(uintptr_t sp) = NULL; +#endif + static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #ifdef SCR_ASAN_FIBERS ScrFiber *from_fiber = scr_current; +#endif +#ifdef SCR_NODE_ISLANDS + uintptr_t node_low = scr_node_stack_low; + if (to_fiber != NULL && scr_node_stack_has != NULL) { + uintptr_t here = (uintptr_t)__builtin_frame_address(0); + if (scr_node_stack_has(here)) scr_node_stack_low = here; + } #endif scr_current = to_fiber; scr_exc_swap_cell(to_fiber ? &to_fiber->exc : NULL); @@ -1642,6 +1659,9 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #if !defined(_WIN32) && !defined(__wasi__) SCR_STACK_GUARD_SET(guard); #endif +#ifdef SCR_NODE_ISLANDS + scr_node_stack_low = node_low; /* back on the stack that switched away */ +#endif } static void scr_promise_settle_wake(ScrPromise *p); diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index f63d74959..1c6bfbea3 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -313,8 +313,15 @@ static void ni_nested_trampoline(void) { static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { ucontext_t nested, caller; char *lo = ni_stack + NI_GUARD; - /* Below the red zone and 16-byte aligned, as makecontext expects. */ - uintptr_t hi = (ni_mark - 512) & ~(uintptr_t)15; + /* Below every live frame of Node's stack: the mark the host callback + * recorded, or lower where runtime code it ran went deeper before it + * switched to this fiber (an async call's eager start, a job: the + * spawner's frame and its saved context are still in use there). Below + * the red zone and the swap's own frame, 16-byte aligned, as makecontext + * expects. */ + uintptr_t top = ni_mark; + if (scr_node_stack_low != 0 && scr_node_stack_low < top) top = scr_node_stack_low; + uintptr_t hi = (top - 1024) & ~(uintptr_t)15; if (getcontext(&nested) != 0) scr_trap("scriptc: getcontext failed\n"); nested.uc_stack.ss_sp = lo; nested.uc_stack.ss_size = hi - (uintptr_t)lo; @@ -354,6 +361,7 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) { ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); if (ni_stack == MAP_FAILED) ni_oom(); (void)mprotect(ni_stack, NI_GUARD, PROT_NONE); + scr_node_stack_has = &ni_stack_has; #ifdef NI_ASAN if (__lsan_register_root_region) __lsan_register_root_region(ni_stack, NI_STACK); #endif @@ -500,13 +508,27 @@ static const ScrNodeBridgeHost ni_host = { &scr_ni_host_export_op, }; +typedef struct { + uint32_t event, listener; + const char *name; + int arg; +} NiEmit; + +static void ni_emit_job(void *p) { + NiEmit *e = p; + ni_bridge->emit(e->event, e->listener, e->name, e->arg); +} + +/* An island listener of a process event, on Node's stack (an exit listener + * runs from the runtime's exit sequence, on whatever stack called it). */ void scr_node_island_emit(uint32_t event, uint32_t listener, ScrStr *name, int arg) { if (ni_bridge == NULL) return; char *n = malloc(name->len + 1); if (n == NULL) ni_oom(); memcpy(n, name->data, name->len); n[name->len] = '\0'; - ni_bridge->emit(event, listener, n, arg); + NiEmit e = {event, listener, n, arg}; + ni_on_node_stack(ni_emit_job, &e); free(n); } @@ -549,7 +571,75 @@ static int ni_run_loop(int continue_turn) { return a.code; } -/* The bridge as the hosted loop sees it: Node's loop runs on Node's stack. */ +/* The bridge as the hosted loop sees it. Every entry that runs JavaScript + * (scheduling a tick, timer or immediate goes through the bootstrap's + * helpers; a checkpoint drains queues; Node's loop) runs on Node's stack, + * below the mark when it comes from an async fiber that a host callback + * resumed, like every other entry: V8 scans only Node's stack, so no frame + * of Node may live on a fiber's stack or the main thread's. */ +typedef struct { + int op; + uint32_t a, b; + double d0, d1; + int i0; + uint32_t result; +} NiHosted; + +enum { + NI_H_MICROTASK, + NI_H_TICK, + NI_H_TIMER, + NI_H_TIMER_CLEAR, + NI_H_TIMER_REF, + NI_H_TIMER_REFRESH, + NI_H_IMMEDIATE, + NI_H_IMMEDIATE_CLEAR, + NI_H_IMMEDIATE_REF, + NI_H_CHECKPOINT, +}; + +static void ni_hosted_job(void *p) { + NiHosted *h = p; + switch (h->op) { + case NI_H_MICROTASK: ni_bridge->microtask(h->a); break; + case NI_H_TICK: ni_bridge->tick(h->a); break; + case NI_H_TIMER: h->result = ni_bridge->timer(h->d0, h->d1, h->i0, h->a); break; + case NI_H_TIMER_CLEAR: ni_bridge->timer_clear(h->a); break; + case NI_H_TIMER_REF: ni_bridge->timer_ref(h->a, h->i0); break; + case NI_H_TIMER_REFRESH: ni_bridge->timer_refresh(h->a); break; + case NI_H_IMMEDIATE: h->result = ni_bridge->immediate(h->i0, h->a); break; + case NI_H_IMMEDIATE_CLEAR: ni_bridge->immediate_clear(h->a); break; + case NI_H_IMMEDIATE_REF: ni_bridge->immediate_ref(h->a, h->i0); break; + case NI_H_CHECKPOINT: ni_bridge->checkpoint(h->i0); break; + default: break; + } +} + +static uint32_t ni_hosted_run(int op, uint32_t a, double d0, double d1, int i0) { + NiHosted h = {op, a, 0, d0, d1, i0, 0}; + ni_on_node_stack(ni_hosted_job, &h); + return h.result; +} + +static void ni_h_microtask(uint32_t token) { ni_hosted_run(NI_H_MICROTASK, token, 0, 0, 0); } +static void ni_h_tick(uint32_t token) { ni_hosted_run(NI_H_TICK, token, 0, 0, 0); } +static uint32_t ni_h_timer(double delay, double repeat, int ref, uint32_t token) { + return ni_hosted_run(NI_H_TIMER, token, delay, repeat, ref); +} +static void ni_h_timer_clear(uint32_t h) { ni_hosted_run(NI_H_TIMER_CLEAR, h, 0, 0, 0); } +static void ni_h_timer_ref(uint32_t h, int ref) { ni_hosted_run(NI_H_TIMER_REF, h, 0, 0, ref); } +static void ni_h_timer_refresh(uint32_t h) { ni_hosted_run(NI_H_TIMER_REFRESH, h, 0, 0, 0); } +static uint32_t ni_h_immediate(int ref, uint32_t token) { + return ni_hosted_run(NI_H_IMMEDIATE, token, 0, 0, ref); +} +static void ni_h_immediate_clear(uint32_t h) { ni_hosted_run(NI_H_IMMEDIATE_CLEAR, h, 0, 0, 0); } +static void ni_h_immediate_ref(uint32_t h, int ref) { + ni_hosted_run(NI_H_IMMEDIATE_REF, h, 0, 0, ref); +} +static void ni_h_checkpoint(int microtasks_first) { + ni_hosted_run(NI_H_CHECKPOINT, 0, 0, 0, microtasks_first); +} + static ScrNodeBridge ni_hosted; /* ── loading Node ─────────────────────────────────────────────────────── */ @@ -623,6 +713,16 @@ static void ni_start(void) { scr_atexit(ni_stop_atexit); ni_hosted = *bridge; ni_hosted.run_loop = &ni_run_loop; + ni_hosted.microtask = &ni_h_microtask; + ni_hosted.tick = &ni_h_tick; + ni_hosted.timer = &ni_h_timer; + ni_hosted.timer_clear = &ni_h_timer_clear; + ni_hosted.timer_ref = &ni_h_timer_ref; + ni_hosted.timer_refresh = &ni_h_timer_refresh; + ni_hosted.immediate = &ni_h_immediate; + ni_hosted.immediate_clear = &ni_h_immediate_clear; + ni_hosted.immediate_ref = &ni_h_immediate_ref; + ni_hosted.checkpoint = &ni_h_checkpoint; scr_loop_host(&ni_hosted, since); /* NI-7: deferred island modules evaluate now, before the operation that * needed Node, in their import order. */ diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 926128222..2241f3fc2 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -34,6 +34,11 @@ ScrPromise *scr_node_island_call(ScrStr *file, ScrStr *specifier, ScrStr *name, struct ScrNodeBridge; struct ScrNodeIoState; void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms); +/* The lowest live frame of Node's stack while a fiber runs (the SCR_NODE_ISLANDS + * build of scr_async.c records it at each switch from Node's stack to a + * fiber; 0: none), and the test for Node's stack it uses. */ +extern uintptr_t scr_node_stack_low; +extern bool (*scr_node_stack_has)(uintptr_t sp); bool scr_loop_is_hosted(void); void scr_loop_hosted_run(uint32_t kind, uint32_t token); void scr_loop_hosted_io_state(struct ScrNodeIoState *state); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 4d03a4898..bb3bcbd7d 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1369,6 +1369,10 @@ static const ScrDynHandleOps nh_ops = { * own objects. */ static void nh_cleanup(void) { nh_drain_freed(); + /* What the last conversion for the export unit kept alive. */ + NhKeep last = nh_out_keep; + memset(&nh_out_keep, 0, sizeof nh_out_keep); + nh_keep_done(&last); if (nh_inspect_fn != NULL) { scr_dyn_release(nh_inspect_fn); nh_inspect_fn = NULL; diff --git a/tests/corpus/6870-node-island-server-fibers/main.ts b/tests/corpus/6870-node-island-server-fibers/main.ts new file mode 100644 index 000000000..829f796c1 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/main.ts @@ -0,0 +1,91 @@ +// @requires-libnode +// An HTTP server whose async request handler awaits island promises, calls +// island functions, extends an island class and arms timers, while an +// island client drives it (a long-running service's shape): the handler's +// continuations run as runtime jobs that Node's loop +// dispatches, possibly on an async fiber's stack, and every entry into Node +// from there must run on Node's stack, where V8 scans it during the +// collections the island's allocations cause. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { churn, delay, depth, get, Handler } from "server-kit"; + +class CountingHandler extends Handler { + served = 0; + async handle(path: string): Promise { + this.served++; + return `${await super.handle(path)}#${this.served}`; + } +} + +const handler = new CountingHandler("kit"); +const timers: string[] = []; + +// Called from a continuation (a runtime job on Node's stack): its eager +// start runs on a fiber and calls into the island, deep, before its first +// await returns to the caller's frame on Node's stack. +async function listFiles(path: string, wait: (n: number) => Promise): Promise { + const out: string[] = []; + try { + for (let i = 0; i < 3; i++) { + out.push(`${path}:${depth(300 + i)}`); + } + const more = await wait(out.length); + out.push(String(more)); + } finally { + out.push("done"); + } + return out; +} + +// Starts listFiles a few native frames deeper on the current stack. +function startDeep(levels: number, path: string): Promise { + const parts = [path, String(levels), path.length.toString(16)]; + if (levels > 0) return startDeep(levels - 1, parts[0]!); + return listFiles(parts.join("").slice(0, path.length), (n) => delay(1, n)); +} + +async function respond(path: string): Promise { + const waited = await delay(1, path); + const files = (await startDeep(24, waited)) as string[]; + let total = 0; + for (let i = 0; i < 3; i++) { + total += churn(i) > 0 ? 1 : 0; + await delay(0); + } + const handled = await handler.handle(waited); + await new Promise((resolve) => + setTimeout(() => { + timers.push(path); + resolve(); + }, 1), + ); + return `${handled} churn=${total} files=${files.length}`; +} + +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const path = req.url ?? "/"; + respond(path).then( + (text) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(text); + }, + (error: unknown) => { + res.writeHead(500); + res.end(String(error)); + }, + ); +}); + +async function main(): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + for (let i = 0; i < 40; i++) { + const reply = await get(port, `/r${i}`); + if (i % 10 === 0 || reply.status !== 200) console.log(reply.status, reply.body); + } + console.log("timers", timers.length, "served", handler.served, "count", handler.count); + server.close(() => console.log("closed")); +} + +void main(); diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts new file mode 100644 index 000000000..d4ae40527 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts @@ -0,0 +1,14 @@ +export interface Reply { + status: number; + body: string; +} +export declare function get(port: number, path: string): Promise; +export declare function delay(ms: number, value?: T): Promise; +export declare function churn(seed: number): number; +export declare function depth(n: number): number; +export declare class Handler { + name: string; + count: number; + constructor(name: string); + handle(path: string): Promise; +} diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js new file mode 100644 index 000000000..31ba6ed3b --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js @@ -0,0 +1,53 @@ +"use strict"; +// An island package shaped like a service's HTTP helpers: a +// client that requests over HTTP and answers promises, a base class that +// native code extends, and work that allocates enough to collect garbage. +const http = require("node:http"); + +function get(port, path) { + return new Promise((resolve, reject) => { + http + .get({ host: "127.0.0.1", port, path, agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body })); + }) + .on("error", reject); + }); +} + +function delay(ms, value) { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} + +// Allocates and drops a few megabytes, so collections run while native +// callers are on the stack. +function churn(seed) { + let keep = 0; + for (let i = 0; i < 2000; i++) { + const a = new Array(64).fill(seed + i); + keep += a.length + JSON.stringify({ i, a: a.slice(0, 4) }).length; + } + return keep; +} + +// Recursion deep enough to use tens of kilobytes of the stack it runs on. +function depth(n) { + const pad = [n, n + 1, n + 2, n + 3]; + return n <= 0 ? pad.length : depth(n - 1) + (pad[n % 4] > 0 ? 1 : 0); +} + +class Handler { + constructor(name) { + this.name = name; + this.count = 0; + } + async handle(path) { + this.count++; + await delay(1); + return `${this.name}:${path}`; + } +} + +module.exports = { get, delay, churn, depth, Handler }; diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json new file mode 100644 index 000000000..234acb589 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json @@ -0,0 +1 @@ +{ "name": "server-kit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6870-node-island-server-fibers/package.json b/tests/corpus/6870-node-island-server-fibers/package.json new file mode 100644 index 000000000..26859cb33 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/package.json @@ -0,0 +1 @@ +{ "name": "island-server-fibers", "private": true, "type": "module", "scriptc": { "packages": { "server-kit": "node" } } } diff --git a/tests/fixtures/node-island/exit-tls.cjs b/tests/fixtures/node-island/exit-tls.cjs new file mode 100644 index 000000000..230902e81 --- /dev/null +++ b/tests/fixtures/node-island/exit-tls.cjs @@ -0,0 +1,16 @@ +// Island work that keeps OpenSSL busy while the program exits +// (exit-tls.mjs): loading node:tls starts Node's off-thread loading of the +// root certificates (system ones with NODE_USE_SYSTEM_CA=1, extra ones with +// NODE_EXTRA_CA_CERTS); pbkdf2 and key generation run on libuv's pool. +"use strict"; +const crypto = require("node:crypto"); +const tls = require("node:tls"); + +exports.warm = function warm(mode) { + if (mode === "load") return tls.DEFAULT_MIN_VERSION.length > 0; + tls.createSecureContext(); + const roots = tls.rootCertificates.length > 0; + for (let i = 0; i < 8; i++) crypto.pbkdf2("password", "salt", 20000, 64, "sha512", () => {}); + if (mode === "keys") crypto.generateKeyPair("ec", { namedCurve: "P-256" }, () => {}); + return roots; +}; diff --git a/tests/fixtures/node-island/exit-tls.mjs b/tests/fixtures/node-island/exit-tls.mjs new file mode 100644 index 000000000..29dfd69e1 --- /dev/null +++ b/tests/fixtures/node-island/exit-tls.mjs @@ -0,0 +1,20 @@ +// Exit while Node has OpenSSL work in flight (node-island-exit.test.ts): +// Node must stop its threads before the process's exit handlers tear +// OpenSSL down. argv[2]: "early" (exit right after Node starts, while it +// loads the system root certificates on a thread of its own with +// NODE_USE_SYSTEM_CA=1), "load" (node:tls loaded, extra certificates +// loading off-thread), "exit" (process.exit with pool work in flight), +// "keys", or "return" (fall off the end). A "-return" suffix ends the +// program by returning instead of process.exit (C exit and its handlers). +import { requireModule } from "@scriptc/node"; + +const arg = process.argv[2] ?? "exit"; +const returns = arg.endsWith("-return") || arg === "return"; +const mode = arg.replace(/-return$/, ""); +if (mode === "early") { + console.log("roots", requireModule("./helper.cjs") !== null); +} else { + const lib = requireModule("./exit-tls.cjs"); + console.log("roots", lib.warm(mode)); +} +if (!returns) process.exit(0); diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp index 41125589f..a0fe1c5ae 100644 --- a/tests/harness/lsan.supp +++ b/tests/harness/lsan.supp @@ -2,3 +2,24 @@ # (scripts/ci-test-plan.mjs). One `leak:` line per intentional # leak, under a comment that gives the reason. Empty: no corpus program # leaks. + +# Node islands (scope-B NI-7c): OpenSSL inside libnode is initialized with +# OPENSSL_INIT_NO_ATEXIT (scr_node_bridge.cc, KeepOpenSSLAtExit), so its +# process-lifetime state is never freed: OPENSSL_cleanup at exit raced the +# threads Node owns that can still be inside OpenSSL. +leak:OPENSSL_init_crypto +# Node islands (scope-B NI-7c): Node's root certificates (bundled, system +# store, NODE_EXTRA_CA_CERTS) live in function-local statics that Node's +# per-process teardown frees; a scriptc program leaves that teardown to +# process exit (scr_node_bridge.cc, Stop), and the vectors' own destructors +# drop the last pointers at exit. +leak:node::crypto::LoadCACertificates +leak:node::crypto::LoadSystemCACertificates +leak:node::crypto::GetBundledRootCertificates +leak:node::crypto::GetSystemStoreCACertificates +leak:node::crypto::GetExtraCACertificates +leak:node::crypto::GetOpenSSLSystemCertificates +leak:node::crypto::LoadCertsFromBIO +# The certificates' lazily decoded keys, cached in them when a root store +# is built. +leak:node::crypto::NewRootCertStore diff --git a/tests/harness/node-island-exit.test.ts b/tests/harness/node-island-exit.test.ts new file mode 100644 index 000000000..eb69623f4 --- /dev/null +++ b/tests/harness/node-island-exit.test.ts @@ -0,0 +1,87 @@ +/* Exit with Node loaded (scope-B NI-7c): island code loads node:tls (Node + * then loads the root certificates on threads of its own) or leaves OpenSSL + * work on libuv's thread pool, and the program exits at once. The bridge's + * stop runs Node's own exit sequence (join the pool, dispose the platform, + * join the certificate threads) before the process's exit handlers run + * OpenSSL's cleanup; without it a certificate thread inside OpenSSL crashed + * the exit (SIGSEGV/SIGABRT in up to half the runs). + * Many runs per mode; SCRIPTC_NODE_ISLAND_EXIT_RUNS scales them. Needs a + * libnode pack in SCRIPTC_LIBNODE. */ +import { execFile } from "node:child_process"; +import { existsSync, 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 fixture = join(repoRoot, "tests/fixtures/node-island/exit-tls.mjs"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-exit"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const pack = libnodePack(); +const runs = Number(process.env["SCRIPTC_NODE_ISLAND_EXIT_RUNS"] ?? (sanitize ? "10" : "40")); + +let binary = ""; +// Node loads the system and extra root certificates on their own threads +// when node:tls loads, with these set: the threads the exit must wait for. +const caFile = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"].find((f) => + existsSync(f), +); +const env: NodeJS.ProcessEnv = { + ...process.env, + NODE_USE_SYSTEM_CA: "1", + ...(caFile === undefined ? {} : { NODE_EXTRA_CA_CERTS: caFile }), +}; + +async function runOnce(mode: string): Promise<{ code: number | string; stdout: string }> { + try { + const { stdout } = await execFileAsync(binary, [mode], { encoding: "utf8", env }); + return { code: 0, stdout }; + } catch (err) { + const e = err as { code?: number; signal?: string; stdout?: string }; + return { code: e.signal ?? e.code ?? "?", stdout: e.stdout ?? "" }; + } +} + +describe.skipIf(pack === null)("Node islands: exit with OpenSSL work in flight", () => { + beforeAll(async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(fixture, { + outPath: join(outDir, "exit-tls"), + outDir, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + binary = result.binaryPath; + }, 300_000); + + test.each([ + "early", + "load", + "exit", + "keys", + "return", + "early-return", + "load-return", + "keys-return", + ])( + "%s: every run exits 0", + async (mode) => { + const outcomes: (number | string)[] = []; + for (let i = 0; i < runs; i += 4) { + const batch = await Promise.all( + Array.from({ length: Math.min(4, runs - i) }, () => runOnce(mode)), + ); + for (const r of batch) { + outcomes.push(r.code); + expect(r.stdout).toBe(`roots true\n`); + } + } + expect(outcomes.filter((c) => c !== 0)).toEqual([]); + }, + 600_000, + ); +}); From b388156593cdfecddead67818ec0f5574bbd9fc4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:08:34 -0700 Subject: [PATCH 03/47] Make packages islands only for fences that surely fire; keep jose and run-node native without pins A gap fence inside a function body may never run, so it no longer makes its package a Node island under --node-islands=auto: only fences in top-level module code and fences that need the JavaScript runtime do. The package's island class still decides after a failed build. Typed packages like jose stay native (DECISIONS.md, 2026-10-21), so the corpus pins of jose and run-node are gone. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/executable/prepare.ts | 16 ++++++++++++---- tests/corpus/6331-jose-root-entry/package.json | 1 - .../6610-npm-execa-child-mixin/package.json | 1 - 3 files changed, 12 insertions(+), 6 deletions(-) delete mode 100644 tests/corpus/6331-jose-root-entry/package.json delete mode 100644 tests/corpus/6610-npm-execa-child-mixin/package.json diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 90504d0ef..5f3e45c9a 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -461,10 +461,13 @@ function preflightIslands( /** Packages compiled natively with runtime fences (refusals deferred to the * operation that reaches them) that should run in islands instead: those - * whose island class is runtime (R1) or object-model (R2), by NI-2's report - * over the pre-scan markers and the fences, or with a fence that itself - * needs the runtime. Gap-only packages keep their fences until decision 8 - * (D6) retires them. `--node-islands=auto` only; null for none. */ + * with a fence that surely fires (one in top-level module code, which runs + * when the module evaluates) or that itself needs the runtime (R1). Other + * fences stay deferred refusals until decision 8 (D6) retires them, so + * typed packages like jose stay native. With `requireFence` false (after a + * failed build) the package's island class by NI-2's report decides + * instead: runtime (R1) or object-model (R2). `--node-islands=auto` only; + * null for none. */ function fenceIslands( entryPath: string, opts: CompileRequestOptions, @@ -515,6 +518,11 @@ function fenceIslands( if (runtimeFence === undefined || island.path === null) continue; if (!compiled.has(island.package) || known.has(island.package)) continue; if (policy.forcedNative.has(island.package) || policy.unloaded.has(island.package)) continue; + // With fences (requireFence), only fences that surely fire count: a + // gap fence inside a function body may never run (jose's typed paths + // stay native, DECISIONS.md 2026-10-21). After a failed build + // (!requireFence), the package's report class decides. + if (requireFence && !runtimeFence) continue; if (!runtimeFence && island.class !== "runtime" && island.class !== "object-model") continue; grown.set(island.package, { name: island.package, diff --git a/tests/corpus/6331-jose-root-entry/package.json b/tests/corpus/6331-jose-root-entry/package.json deleted file mode 100644 index 30d3e0af0..000000000 --- a/tests/corpus/6331-jose-root-entry/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module", "scriptc": { "packages": { "jose": "native" } } } diff --git a/tests/corpus/6610-npm-execa-child-mixin/package.json b/tests/corpus/6610-npm-execa-child-mixin/package.json deleted file mode 100644 index 5a418a30c..000000000 --- a/tests/corpus/6610-npm-execa-child-mixin/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module", "scriptc": { "packages": { "run-node": "native" } } } From 3b2d5f202228b102c46a6a1f1434204973724ebb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:13:46 -0700 Subject: [PATCH 04/47] Node islands: give a nested entry its own scheduling state A call into Node from an async fiber runs in a context of its own on Node's stack while the calling fiber stays suspended in it, but runtime code that Node called back there still ran as that fiber: scr_current named it, and a fiber resumed from there saved its resumer in scr_loop_ctx and, under ASan, its fake stack in the calling fiber's slot. The calling fiber's next park then returned to a context that no longer existed, or under ASan resumed with a fake stack the nested context had destroyed (6870-node-island-server-fibers in dev mode, sanitized: SEGV in nh_keep_done). The nested entry now runs host callbacks as the loop's code does (no current fiber, the main exception cell and ALS slot) and puts the calling fiber's state and the loop context back when it returns. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_async.c | 48 ++++++++++++++++++++++++++ packages/runtime/src/scr_node_island.c | 2 ++ packages/runtime/src/scr_node_island.h | 5 +++ 3 files changed, 55 insertions(+) diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 921c73648..64a3712a3 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1664,6 +1664,54 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #endif } +#ifdef SCR_NODE_ISLANDS +/* A nested entry into Node (scr_node_island.c) runs on Node's stack in a + * context of its own while the fiber that made the call stays suspended in + * it. Runtime code that Node calls back there runs as the loop's code does: + * no current fiber, the main exception cell and ALS slot. It may resume + * other fibers, which saves the resumer's context in scr_loop_ctx (and, + * under ASan, its fake stack in the main slot); those belong to whoever + * resumed the calling fiber, so they are saved here and put back when the + * nested entry returns, or the calling fiber's next park would return to a + * context that no longer exists. */ +typedef struct { + ScrFiber *current; + ScrAlsCtx **als; + ScrCtx loop_ctx; +#ifdef SCR_ASAN_FIBERS + void *main_fake; +#endif +} ScrNestedSave; + +void *scr_loop_nested_enter(void) { + ScrNestedSave *s = malloc(sizeof *s); + if (s == NULL) scr_oom(); + s->current = scr_current; + s->als = scr_als_active; + s->loop_ctx = scr_loop_ctx; +#ifdef SCR_ASAN_FIBERS + s->main_fake = scr_main_fake_stack; + scr_main_fake_stack = NULL; +#endif + scr_current = NULL; + scr_exc_swap_cell(NULL); + scr_als_active = &scr_als_main_slot; + return s; +} + +void scr_loop_nested_leave(void *save) { + ScrNestedSave *s = save; + scr_loop_ctx = s->loop_ctx; +#ifdef SCR_ASAN_FIBERS + scr_main_fake_stack = s->main_fake; +#endif + scr_current = s->current; + scr_exc_swap_cell(s->current ? &s->current->exc : NULL); + scr_als_active = s->als; + free(s); +} +#endif + static void scr_promise_settle_wake(ScrPromise *p); /* Copies src's settlement into a still-pending dst (payload RETAINED — diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index 1c6bfbea3..c297a3630 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -332,6 +332,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { ni_nested_return = &caller; uintptr_t guard = scr_stack_guard; SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)lo, hi - (uintptr_t)lo)); + void *loop_save = scr_loop_nested_enter(); #ifdef NI_ASAN void *caller_fake = NULL; __sanitizer_start_switch_fiber(&caller_fake, lo, hi - (uintptr_t)lo); @@ -342,6 +343,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { size_t size; __sanitizer_finish_switch_fiber(caller_fake, &bottom, &size); #endif + scr_loop_nested_leave(loop_save); SCR_STACK_GUARD_SET(guard); } diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 2241f3fc2..320328fde 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -39,6 +39,11 @@ void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms); * fiber; 0: none), and the test for Node's stack it uses. */ extern uintptr_t scr_node_stack_low; extern bool (*scr_node_stack_has)(uintptr_t sp); +/* Around a nested entry: the runtime's scheduling state of the suspended + * calling fiber, saved and put back (the SCR_NODE_ISLANDS build of + * scr_async.c). */ +void *scr_loop_nested_enter(void); +void scr_loop_nested_leave(void *save); bool scr_loop_is_hosted(void); void scr_loop_hosted_run(uint32_t kind, uint32_t token); void scr_loop_hosted_io_state(struct ScrNodeIoState *state); From 45c8c292afec43f43475c16c42851b533bc323b4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:15:49 -0700 Subject: [PATCH 05/47] Native plain objects inherit Object.prototype directly in Node islands A plain object a program passes into an island is an object-template instance, which inherits from its implicit constructor's prototype object; island code that checks for plain objects (jose's isObject: Object.getPrototypeOf(o) === Object.prototype) rejected it. The instance now inherits Object.prototype directly. Corpus 6826 checks it. Co-Authored-By: Claude Opus 5.5 --- packages/libnode-common/bridge/scr_node_bridge.cc | 5 +++++ tests/corpus/6826-node-island-undefined-args/main.ts | 3 +++ .../node_modules/argprobe/index.d.ts | 1 + .../node_modules/argprobe/index.js | 8 ++++++++ 4 files changed, 17 insertions(+) diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 584325d50..373b51688 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -1383,6 +1383,11 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode } else { obj = ExportTemplate(isolate)->NewInstance(context).ToLocalChecked(); obj->SetInternalField(0, data); + // A template instance inherits from its implicit constructor's own + // prototype object; a native plain object is a plain object to island + // code (`Object.getPrototypeOf(o) === Object.prototype`, as jose's + // isObject checks), so it inherits Object.prototype directly. + obj->SetPrototypeV2(context, v8::Object::New(isolate)->GetPrototypeV2()).FromMaybe(false); } if (!obj->IsProxy()) obj->SetPrivate(context, ExportKey(isolate), data).Check(); Global &slot = g_exports[id]; diff --git a/tests/corpus/6826-node-island-undefined-args/main.ts b/tests/corpus/6826-node-island-undefined-args/main.ts index 94a350e67..5f6fb53e9 100644 --- a/tests/corpus/6826-node-island-undefined-args/main.ts +++ b/tests/corpus/6826-node-island-undefined-args/main.ts @@ -12,6 +12,7 @@ import { nodeVersionFor, cwd, envValue, + plain, type Config, } from "argprobe"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; @@ -54,6 +55,8 @@ const none = projectSettings(false); console.log(version("/work", "unused", { nodeVersion: project?.nodeVersion }, undefined, available)); console.log(version("/work", "unused", { nodeVersion: none?.nodeVersion }, undefined, available)); console.log(probe("p", { nodeVersion: project?.nodeVersion }), probe("n", { nodeVersion: none?.nodeVersion })); +const claims = { role: "admin" }; +console.log("plain", plain({ sub: "alice" }), plain(claims)); async function main(): Promise { // A package.json the program writes just before the island reads it. diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts index 72cde09b4..6ce43de4d 100644 --- a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts @@ -21,3 +21,4 @@ export declare function nodeVersionFor( ): Promise; export declare function cwd(): string; export declare function envValue(name: string): string | undefined; +export declare function plain(v: object): string; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js index 5a9e4245e..5cf8f9301 100644 --- a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js @@ -68,3 +68,11 @@ exports.nodeVersionFor = async function nodeVersionFor( exports.cwd = () => process.cwd(); exports.envValue = (name) => process.env[name]; + +// A native plain object is a plain object here (jose's isObject). +exports.plain = (v) => + [ + Object.getPrototypeOf(v) === Object.prototype, + Object.prototype.toString.call(v), + Object.getPrototypeOf(Object.getPrototypeOf(v)) === null, + ].join(" "); From 47f44c3dfde89a01952b8327b7346692d2114d78 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:38:49 -0700 Subject: [PATCH 06/47] npm harness: cases that stay native under the fence rule keep their deferred-refusal expectations Co-Authored-By: Claude Opus 5.5 --- tests/harness/npm.test.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index d08a4c0fb..3643a9859 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -66,46 +66,42 @@ const KNOWN: Record = { divergence: "Timeout handles are numbers (typeof) and lose their ref flag once fired or cleared", }, - "hot-shots-udp": { - divergence: - "as a Node island (its runtime fences pick one), output stops after the socket-not-running error; the built-in hot-shots shim (#1836) compiles it natively", - }, "island-web-plumbing": { run: "SC2020" }, "lazy-traps": { island: true }, - "misc-shims": { island: true }, + "misc-shims": { run: "SC2020" }, namespace: { island: true }, "node24-async-events": { run: "SC2020" }, "node24-commonjs-metadata": { island: true }, "node24-crypto-kdf": { run: "SC2020" }, - "node24-deep-equality": { island: true }, + "node24-deep-equality": { run: "SC2020" }, "node24-diagnostics-async": { run: "SC1090" }, "node24-diagnostics-tracing": { run: "SC1090" }, - "node24-env": { island: true }, + "node24-env": { run: "SC1090" }, "node24-esm-metadata": { island: true }, "node24-event-emitter": { run: "SC2020" }, "node24-file-url-conversion": { island: true }, "node24-fs-descriptors": { run: "SC2020" }, - "node24-mime": { island: true }, - "node24-mime-errors": { island: true }, + "node24-mime": { run: "SC2020" }, + "node24-mime-errors": { run: "SC1090" }, "node24-no-error-assertions": { run: "SC2020" }, - "node24-parseargs": { island: true }, + "node24-parseargs": { run: "SC1090" }, "node24-promise-timers": { run: "SC2020" }, "node24-string-decoder": { run: "SC2020" }, - "node24-style-text": { island: true }, + "node24-style-text": { run: "SC2020" }, "node24-system-errors": { run: "SC1090" }, "node24-text-encoding": { run: "SC2020" }, "node24-text-utilities": { run: "SC2020" }, "node24-timers-unref": { run: "SC2020" }, "node24-url-factories": { run: "SC2020" }, "node24-url-mutation": { run: "SC1090" }, - "node24-util-adapters": { island: true }, - "node24-util-types": { island: true }, + "node24-util-adapters": { run: "Object.create with this value is not supported yet" }, + "node24-util-types": { run: "SC1090" }, "node24-web-stream-encoding": { run: "SC1090" }, "node24-web-stream-errors": { run: "SC1090" }, "node24-web-stream-lifecycle": { run: "SC1090" }, "node24-web-stream-pipe-abort": { run: "SC2020" }, "node24-web-stream-pipe-errors": { run: "SC1090" }, - "node24-web-stream-pipes": { island: true }, + "node24-web-stream-pipes": { run: "SC1090" }, "node24-web-stream-queues": { run: "SC1090" }, "node24-web-stream-readable-lifecycle": { run: "SC2020" }, "node24-web-stream-readers": { run: "SC1090" }, @@ -114,8 +110,8 @@ const KNOWN: Record = { "package-imports": { island: true }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { island: true }, - "util-shims": { island: true }, + "stream-shims": { run: "SC1090" }, + "util-shims": { run: "SC2020" }, "web-streams": { run: "SC1090" }, "workspace-copied": { island: true }, "workspace-unclean": { build: "SC2013" }, From 7687cf2d26c068f7c623e23f0af588966955f4ec Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 17:50:36 -0700 Subject: [PATCH 07/47] Type island packages from their declarations in failed builds and generics When a native build failed and the packages it compiled with runtime fences then failed as islands too, the native build's diagnostics were reported. That build types those packages from their JavaScript, where declared-only types (interfaces, `Static`-style aliases) are `any`, so typed user code reported cascades the island build does not have (a schema library's `TSchema`). The island build's errors are reported instead. In a generic function instance over island types (``), a `T` value bound to dyn now crosses into the island the way `any` does, instead of being refused as a native object. Corpus 6829 types user code with an island package's interfaces, generic aliases and a generic function; node-island.test.ts pins the reported diagnostics of a failed build. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/executable/prepare.ts | 14 +++---- .../frontend/lowering/lower-node-island.ts | 16 ++++++-- .../main.ts.json | 6 +++ .../6829-node-island-declared-types/main.ts | 39 +++++++++++++++++++ .../node_modules/schemakit/index.d.ts | 27 +++++++++++++ .../node_modules/schemakit/index.js | 37 ++++++++++++++++++ .../node_modules/schemakit/package.json | 1 + tests/fixtures/node-island/declared-types.ts | 27 +++++++++++++ .../main.ts.json | 1 + tests/harness/node-island.test.ts | 25 ++++++++++++ 10 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json create mode 100644 tests/corpus/6829-node-island-declared-types/main.ts create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json create mode 100644 tests/fixtures/node-island/declared-types.ts create mode 100644 tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 8a5d11caa..2abf95ffb 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -156,18 +156,20 @@ export function prepareExecutableModule( ); } // A failed program whose compiled packages need the runtime or a - // mutable object model (NI-2's report): try them as islands, and - // report this failure if that build fails too. + // mutable object model (NI-2's report): try them as islands. If that + // build fails too, its failure is the one reported. This build typed + // those packages from their JavaScript, where the types they only + // declare (interfaces, aliases such as `Static`) are `any`, so its + // diagnostics describe a program the island build does not compile: + // there the packages' declarations type the user code. const marked = retryFences ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) : null; if (marked !== null) { - const diagnostics = lowered.diagnostics; - const texts = fe.sourceTexts(); fe.dispose(); frontendDisposed(); disposed = true; - const islanded = prepareExecutableModule( + return prepareExecutableModule( entryPath, opts, ffi, @@ -177,8 +179,6 @@ export function prepareExecutableModule( marked, false, ); - if (islanded.ok) return islanded; - return { ok: false, diagnostics, sourceTexts: texts }; } return fail(lowered.diagnostics); } diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index f8ae8250a..f03f9c406 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -871,11 +871,20 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { // NI-5: the runtime exports dyn objects by reference and copies only // primitives, strings and (for now) arrays. return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); - case "refused": + case "refused": { + const type = lowerer.typeOf(node); + // A type parameter the instance binds to dyn (a generic function + // over island types, ``): its values are dyn + // values, and they cross the way `any` values do. + if ( + (type.flags & ts.TypeFlags.TypeParameter) !== 0 && + lowerer.typeParamResolver(type)?.kind === "dyn" + ) + return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); // NI-5: closures, promises, records and class instances cross by // reference (scr_node_island_export.c): their dyn boxes keep the // native referent, and the closure's thunk checks its arguments. - if (exportedByReference(lowerer, lowerer.typeOf(node))) { + if (exportedByReference(lowerer, type)) { const lowered = lowerer.lowerExpr(node); const value = lowerer.coerceToExpected(lowered, DYN); return lowered.type.kind === "object" @@ -884,9 +893,10 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { } return boundaryFail( lowerer, - `a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`, + `a '${lowerer.checker.typeToString(type)}' value (a native object would cross as a copy and lose its identity)`, node, ); + } } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..8a467a09c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6829-node-island-declared-types/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6829-node-island-declared-types/main.ts b/tests/corpus/6829-node-island-declared-types/main.ts new file mode 100644 index 000000000..52c2e5c40 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/main.ts @@ -0,0 +1,39 @@ +// @requires-libnode +// Typed code names an island package's declared interfaces and generic +// aliases: the package's declarations type it, while its values cross the +// boundary as handles. +import { Arr, Num, Str, check, type Static, type TArray, type TNumber, type TSchema, type TString } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const sum: Tool> = { + name: "sum", + parameters: Arr(Num()), + execute: (id, params) => `${id}: ${params.reduce((a, b) => a + b, 0)}`, +}; +const shout: Tool = { + name: "shout", + parameters: Str(), + execute: (id, params) => `${id}: ${params.toUpperCase()}`, +}; + +function run(tool: Tool, input: unknown): string { + if (!check(tool.parameters, input)) return `${tool.name}: rejected ${JSON.stringify(input)}`; + return tool.execute(tool.name, input); +} + +function describeAll(schemas: TSchema[]): string[] { + return schemas.map((s) => `${s.kind} ${s.describe()}`); +} + +console.log(run(sum, [1, 2, 3])); +console.log(run(sum, [1, "2"])); +console.log(run(shout, "quiet")); +console.log(run(shout, 7)); +console.log(describeAll([sum.parameters, shout.parameters, Arr(Arr(Str()))]).join(" | ")); +const tools: Tool[] = [sum, shout]; +console.log(tools.map((t) => t.name).join(",")); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts @@ -0,0 +1,27 @@ +// Types the package only declares: its JavaScript has no interfaces and no +// conditional aliases, so code typed from the JavaScript sees `any` here. +export interface TSchema { + readonly kind: string; + describe(): string; +} +export interface TNumber extends TSchema { + readonly kind: "number"; +} +export interface TString extends TSchema { + readonly kind: "string"; +} +export interface TArray extends TSchema { + readonly kind: "array"; + readonly items: T; +} +export type Static = T extends TNumber + ? number + : T extends TString + ? string + : T extends TArray + ? Static[] + : unknown; +export declare function Num(): TNumber; +export declare function Str(): TString; +export declare function Arr(items: T): TArray; +export declare function check(schema: T, value: unknown): value is Static; diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js @@ -0,0 +1,37 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings +// and a constructor function with prototype methods. scriptc compiles it +// natively only with runtime fences, so it runs in a Node island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Num: () => Num, + Str: () => Str, + Arr: () => Arr, + check: () => check, +}); +module.exports = src_exports; +function Schema(kind, items) { + this.kind = kind; + if (items !== undefined) this.items = items; +} +Schema.prototype.describe = function () { + return this.items === undefined ? this.kind : this.kind + "<" + this.items.describe() + ">"; +}; +function Num() { + return new Schema("number"); +} +function Str() { + return new Schema("string"); +} +function Arr(items) { + return new Schema("array", items); +} +function check(schema, value) { + if (schema.kind === "array") return Array.isArray(value) && value.every((v) => check(schema.items, v)); + return typeof value === schema.kind; +} +0 && (module.exports = { Num, Str, Arr, check }); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json @@ -0,0 +1 @@ +{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/node-island/declared-types.ts b/tests/fixtures/node-island/declared-types.ts new file mode 100644 index 000000000..05835c751 --- /dev/null +++ b/tests/fixtures/node-island/declared-types.ts @@ -0,0 +1,27 @@ +// A failed build whose package runs in a Node island reports the island +// build's errors: there the package's declarations type the program. The +// test builds it beside corpus 6829's schemakit package. +import { Num, check, type Static, type TNumber, type TSchema } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const double: Tool = { + name: "double", + parameters: Num(), + execute: (id, params) => `${id}: ${params * 2}`, +}; +const input: unknown = 4; +if (check(double.parameters, input)) console.log(double.execute(double.name, input)); +// The build's own error: member decorators have no native lowering. +function logged(method: () => number, context: ClassMethodDecoratorContext): void {} +class Counter { + @logged + next(): number { + return 1; + } +} +console.log(new Counter().next()); diff --git a/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..c2f7906fb --- /dev/null +++ b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 62000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index cbb9daf66..85774285e 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -354,3 +354,28 @@ test("recursive JSON types cross into Node", async () => { const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); expect(ir).toContain("nodeIsland.callSync"); }, 300_000); + +// When a build fails and the packages it compiled with runtime fences then +// fail as islands too, the island build's errors are reported. That build +// types the user code from the packages' declarations; the native one +// typed it from their JavaScript, where declared-only types (`Static`) +// are `any`. Needs no libnode pack: both builds fail before linking. +test("a failed build reports the island build's errors", async () => { + const dir = join(outDir, "declared-types"); + rmSync(dir, { recursive: true, force: true }); + cpSync( + join(repoRoot, "tests/corpus/6829-node-island-declared-types/node_modules"), + join(dir, "node_modules"), + { recursive: true }, + ); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/declared-types.ts"), + join(dir, "main.ts"), + ); + const result = await compile(join(dir, "main.ts"), { outPath: join(dir, "main"), outDir: dir }); + expect(result.ok).toBe(false); + if (result.ok) return; + const messages = result.diagnostics.map((d) => `${d.code}: ${d.message}`); + expect(messages.some((m) => m.startsWith("SC1090: method decorators"))).toBe(true); + expect(messages.filter((m) => /Tool|Static|'check'/.test(m))).toEqual([]); +}, 300_000); From d90577d5eb9337bc0285c4f90355cbd8ea175f68 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 02:05:01 -0500 Subject: [PATCH 08/47] Expect the word-segments pilot to segment in its Node island --- tests/harness/npm-static.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 076733739..fb469ec22 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,6 +15,7 @@ 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, "../.."); @@ -586,7 +587,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => { + // 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 () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); @@ -599,12 +603,15 @@ 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, []); - expect(nativeRes.stdout.toString()).toMatch( - /^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/, - ); + expect(nativeRes.stdout.toString()).toBe("two| |words\n"); expect(nativeRes.exitCode).toBe(0); }); From f0b6e362a4a4fe50e713fad1866dd31fa29c3e72 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 02:05:06 -0500 Subject: [PATCH 09/47] Expect the word-segments pilot to segment in its Node island --- tests/harness/npm-static.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 076733739..fb469ec22 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,6 +15,7 @@ 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, "../.."); @@ -586,7 +587,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => { + // 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 () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); @@ -599,12 +603,15 @@ 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, []); - expect(nativeRes.stdout.toString()).toMatch( - /^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/, - ); + expect(nativeRes.stdout.toString()).toBe("two| |words\n"); expect(nativeRes.exitCode).toBe(0); }); From b425eeca6ec1e0609c2360ffc3c1a4ea5cc0a38f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 16:07:11 -0700 Subject: [PATCH 10/47] Move SC8021 and SC8023 from the reserved to the active codes --- packages/compiler/src/diagnostics/codes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index a40635849..5d05f64ee 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -116,7 +116,9 @@ export const DIAGNOSTIC_CODES = { SC8010: "value crossing a Node island boundary in a form without an exact copy", SC8011: "native class extending a Node island class outside the supported forms", SC8020: "stateful module duplicated across the Node island boundary", + SC8021: "Node island evaluation notes (deferred or at startup)", SC8022: "island code extends a class of a compiled module", + SC8023: "Node island build failed; built natively with runtime fences", SC8030: "type patch: no range covers the installed copy", SC8031: "type patch: selector matches nothing", SC8032: "type patch: declaration already has the type", @@ -155,8 +157,6 @@ export const RETIRED_DIAGNOSTIC_CODES = { * abandoned, delete them here. */ export const RESERVED_DIAGNOSTIC_CODES = { SC8012: "Node islands (ni/* branches)", - SC8021: "Node islands (ni/* branches)", - SC8023: "Node islands (ni/* branches)", } as const satisfies Record<`SC${number}`, string>; export type DiagnosticCode = keyof typeof DIAGNOSTIC_CODES; From af19aa1439e13142740c2a968ed58c186e00e518 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 16:07:12 -0700 Subject: [PATCH 11/47] Do not count a synthesized fence as module code Main's lookup for builtin globals read through the global object lowers every global's value, and its fences have no source position, so they were marked as module-init fences and made packages like jose islands. The lookup runs when the program calls it. --- packages/compiler/src/frontend/lowering/lowerer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index da9cb9108..974306187 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1168,6 +1168,10 @@ export function lowerToIr( function markModuleInitFences(program: ts.Program, fences: readonly ScrDiagnostic[]): void { for (const d of fences) { if (!d.loc.file.includes("/node_modules/")) continue; + // A fence without a source position belongs to synthesized code, such + // as the global object's lookup (global-values.ts), which runs when the + // program calls it, not when the module evaluates. + if (d.loc.start < 0) continue; const sf = program.getSourceFile(d.loc.file); if (sf === undefined) continue; let inFunction = false; From 41e7950ea91bfcdb70e55e1aa7976b0666dd8f08 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 23:07:13 -0500 Subject: [PATCH 12/47] Keep frozen checked records frozen, and give sloppy-mode this the global object Typed records checked from frozen untyped values stay frozen, as the objects they copy are. A sloppy-mode function called with undefined or null as this receives the global object that globalThis reads. The record check reads the source's integrity with a query that answers for every value: a proxy answers as its target, and values without a modeled integrity level, which Object.freeze refuses, are not frozen. Object.isFrozen keeps its refusal of those values. --- .../builtin-apis/object-freezing.mdx | 1 + .../language-features/function-values.mdx | 2 + packages/compiler/src/backend/llvm/dyn.ts | 15 ++++++ .../src/backend/llvm/reference-writes.ts | 1 + .../src/frontend/lowering/frozen-records.ts | 29 +++++++---- .../src/frontend/lowering/lower-functions.ts | 28 +++++++--- .../frontend/lowering/lower-global-value.ts | 26 ++++++++-- .../compiler/src/ir/builtin-signatures.ts | 2 + .../frozen-checked-records-sloppy.cjs.json | 6 +++ .../corpus/frozen-checked-records.ts.json | 6 +++ .../tests/corpus/sloppy-this-global.cjs.json | 6 +++ packages/runtime/src/scr_dyn.h | 5 ++ packages/runtime/src/scr_json.c | 35 ++++++++++--- .../corpus/frozen-checked-records-sloppy.cjs | 37 +++++++++++++ tests/corpus/frozen-checked-records.ts | 29 +++++++++++ tests/corpus/sloppy-this-global.cjs | 52 +++++++++++++++++++ .../frozen-checked-records-sloppy.cjs.json | 1 + .../frozen-checked-records.ts.json | 1 + .../corpus-costs/sloppy-this-global.cjs.json | 1 + 19 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json create mode 100644 tests/corpus/frozen-checked-records-sloppy.cjs create mode 100644 tests/corpus/frozen-checked-records.ts create mode 100644 tests/corpus/sloppy-this-global.cjs create mode 100644 tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json create mode 100644 tests/harness/corpus-costs/frozen-checked-records.ts.json create mode 100644 tests/harness/corpus-costs/sloppy-this-global.cjs.json diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 030b03b4b..80281bb2d 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -5,6 +5,7 @@ A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. + A typed record checked from a frozen untyped object (an object literal frozen in a JavaScript file, or a frozen `JSON.parse` result) is frozen too, and writes through its typed references are checked the same way. The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. diff --git a/docs/content/limitations/language-features/function-values.mdx b/docs/content/limitations/language-features/function-values.mdx index 7083f865d..32af01186 100644 --- a/docs/content/limitations/language-features/function-values.mdx +++ b/docs/content/limitations/language-features/function-values.mdx @@ -5,3 +5,5 @@ - **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them. A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element. Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction. + A sloppy-mode function called with `undefined` or `null` as its receiver gets the global object as `this`, as in Node. + Called with a primitive receiver, its `this` is the primitive itself, where Node passes the primitive's wrapper object (`typeof this` is `"object"` there). diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index dfeffdecb..58bad60af 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2889,6 +2889,21 @@ export class LlDyn { B.line(`call void @scr_str_release(ptr ${ek})`); }); } + // A record built from a frozen object is frozen too, as the object it + // copies (frozen-records.ts makes such shapes freezable). + if (shape.freezable) { + host.declare(`declare zeroext i1 @scr_dyn_checked_record_frozen(ptr)`); + host.declare(`declare void @scr_record_freeze(ptr)`); + const frozen = B.tmp(); + const lFreeze = B.newLabel("dcr.freeze"); + const lDone = B.newLabel("dcr.frozen"); + B.line(`${frozen} = call i1 @scr_dyn_checked_record_frozen(ptr %d)`); + B.condBr(frozen, lFreeze, lDone); + B.startBlock(lFreeze); + B.line(`call void @scr_record_freeze(ptr %r0)`); + B.br(lDone); + B.startBlock(lDone); + } B.terminate(`ret ptr %r0`); break; } diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 56379a443..c352b5eb4 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -94,6 +94,7 @@ const INERT_LIB_CALLS = new Set([ "dyn.compare", // Reads the ambient receiver (a fresh reference to the bound value). "dyn.this", + // A sloppy-mode function's receiver from it (a fresh reference). "error.new", "error.nodeThrow", "dyn.typedRefIs", diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 3700b57ff..75056b3cc 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -121,6 +121,8 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; + const checked: IrType[] = []; + let freezesUntyped = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -130,19 +132,28 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "dynFrom") live.push(e.value.type); + } else if (e.kind === "libCall" && e.fn === "dyn.freeze") freezesUntyped = true; + else if (e.kind === "dynFrom") live.push(e.value.type); + else if (e.kind === "dynCheck") checked.push(e.type); return true; }, stmt: () => true, }); - if (!lowerer.untypedIntegrity) return { shapes, arrays }; - // 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. - const stack = [...live]; - for (const info of lowerer.classes.values()) - for (const field of info.def.fields) stack.push(field.type); + if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays }; + const stack: IrType[] = []; + // 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 (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 info of lowerer.classes.values()) + for (const field of info.def.fields) stack.push(field.type); + } const seen = new Set(); while (stack.length > 0) { const type = stack.pop()!; diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 7f4c8dfb7..263cbcb5f 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -38,6 +38,8 @@ import { blockBodyOf, } from "./function-returns.js"; import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; +import { isStrictCode } from "./strict-mode.js"; +import { lowerSloppyGlobalReceiver } from "./lower-global-value.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -48,16 +50,28 @@ function declareCallReceiver( ): IrStmt { const receiver = lowerer.declareThis(DYN); const loc = locOf(node); + const bound: IrExpr = { + kind: "libCall", + fn: generator ? "dyn.generatorThis" : "dyn.this", + args: [], + type: DYN, + loc, + }; + // A sloppy-mode function's `this` is the global object when it is called + // with undefined or null. The function's own body decides its mode. + const sloppy = !ts.isArrowFunction(node) && node.body !== undefined && !isStrictCode(node.body); return { kind: "varDecl", localId: receiver.id, - init: { - kind: "libCall", - fn: generator ? "dyn.generatorThis" : "dyn.this", - args: [], - type: DYN, - loc, - }, + init: sloppy + ? { + kind: "nullish", + left: bound, + right: lowerSloppyGlobalReceiver(lowerer, node, loc), + type: DYN, + loc, + } + : bound, loc, }; } diff --git a/packages/compiler/src/frontend/lowering/lower-global-value.ts b/packages/compiler/src/frontend/lowering/lower-global-value.ts index e7ab6b0f0..53e538298 100644 --- a/packages/compiler/src/frontend/lowering/lower-global-value.ts +++ b/packages/compiler/src/frontend/lowering/lower-global-value.ts @@ -1,5 +1,5 @@ import * as ts from "../ts7/adapter.js"; -import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js"; +import { DYN, STRING, arrayOf, type IrExpr, type SrcLoc } from "../../ir/ir.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -8,11 +8,29 @@ import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; * Known globals without a value implementation refuse at runtime; absent host * capabilities answer undefined. The checker supplies names, never values. */ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr { - const loc = locOf(expr); + return lowerGlobalValueOfType(lowerer, lowerer.typeOf(expr), locOf(expr)); +} + +/** The global object a sloppy-mode function receives as `this` when it is + * called with undefined or null: the value `globalThis` reads. */ +export function lowerSloppyGlobalReceiver(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrExpr { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(node), + false, + ); + return lowerGlobalValueOfType( + lowerer, + symbol === undefined ? undefined : lowerer.checker.getTypeOfSymbol(symbol), + loc, + ); +} + +function lowerGlobalValueOfType(lowerer: Lowerer, type: ts.Type | undefined, loc: SrcLoc): IrExpr { // The temporal global is typed for the programs that name it (TS 7's // lib slice) but absent in Node 24: reads answer undefined. - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(expr)) + const names = (type === undefined ? [] : lowerer.checker.getPropertiesOfType(type)) .map((property) => ts.symbolName(property)) .filter((name) => name !== TEMPORAL_GLOBAL); return { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 65b560a22..e9d6f04c6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3556,6 +3556,8 @@ function buildLibFnSignatures() { "process.pid": { argTypes: [], result: F64 }, "dyn.this": { argTypes: [], result: DYN }, "dyn.generatorThis": { argTypes: [], result: DYN }, + /** A sloppy-mode function's `this` from its receiver: the global object + * for undefined and null, else the receiver. Never throws. */ "process.getuid": { argTypes: [], result: F64 }, "process.getgid": { argTypes: [], result: F64 }, /** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..7f5478af9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json new file mode 100644 index 000000000..a0771e0cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json new file mode 100644 index 000000000..0bc118bd0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index fafa16eda..039187426 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1095,6 +1095,11 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value); * the programs that link it. */ ScrDyn *scr_dyn_freeze_data(ScrDyn *value); bool scr_dyn_is_frozen(const ScrDyn *value); +/* Whether a record checked from `value` is frozen: `value` is. It answers + * for every value, where scr_dyn_is_frozen refuses values without a modeled + * integrity level: those are not frozen, as Object.freeze refuses them. A + * proxy is frozen exactly when its target is (the proxy invariants). */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value); ScrDyn *scr_dyn_new_bigint(ScrBigInt *value); /* borrows and retains value */ ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */ ScrSym *scr_sym_well_known(ScrStr *name); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 1177231ee..ce637eed9 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -19210,26 +19210,29 @@ ScrDyn *scr_dyn_freeze_data(ScrDyn *value) { return NULL; } -bool scr_dyn_is_frozen(const ScrDyn *value) { - if (scr_dyn_class_reflection_fence(value)) return false; +/* Object.isFrozen of `value` with `self` for the values it contains. A value + * without an integrity level the runtime models refuses with SC2020 when + * `refuse` is set, and otherwise answers false: Object.freeze refuses such + * a value too, so it is never frozen. */ +static bool scr_dyn_frozen_answer(const ScrDyn *value, bool (*self)(const ScrDyn *), bool refuse) { if (scr_dyn_published_object(value)) return true; bool answer; - if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer; + if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, self, &answer)) return answer; ScrMap *collection = scr_dyn_native_collection(value); if (collection) return !scr_map_is_extensible(collection); - if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false; + if (value->symbol_properties && !self(value->symbol_properties)) return false; if (scr_dyn_plain_fn(value)) { ScrDyn *table = scr_dyn_fn_existing_table(value); - answer = table && scr_dyn_is_frozen(table); + answer = table && self(table); scr_dyn_release(table); return answer; } if (value->kind == SCR_DYN_ARR) return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) && - (!value->v.arr.properties || scr_dyn_is_frozen(value->v.arr.properties)); + (!value->v.arr.properties || self(value->v.arr.properties)); if (value->kind == SCR_DYN_BYTES) return (value->v.bytes->is_data_view || value->v.bytes->len == 0) && value->v.bytes->properties && - scr_dyn_is_frozen(value->v.bytes->properties); + self(value->v.bytes->properties); if (value->kind == SCR_DYN_OBJ && !value->copied_from_native) { if (!value->non_extensible) return false; for (size_t i = 0; i < value->v.obj.len; i++) { @@ -19241,11 +19244,29 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { } if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NUM || value->kind == SCR_DYN_BOOL || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BIGINT || value->kind == SCR_DYN_SYMBOL) return true; + if (!refuse) return false; static const char message[] = "Object.isFrozen of this native value has no lowering"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return false; } +bool scr_dyn_is_frozen(const ScrDyn *value) { + if (scr_dyn_class_reflection_fence(value)) return false; + return scr_dyn_frozen_answer(value, scr_dyn_is_frozen, true); +} + +/* A record checked from `value` copies its integrity: whether `value` is + * frozen, answered for every value the check can meet. A proxy is frozen + * exactly when its target is (the proxy invariants). Object.prototype and + * class constructors, which Object.isFrozen fences, and native values + * without a modeled integrity level are not frozen. */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value) { + while (value && value->kind == SCR_DYN_PROXY) value = value->v.proxy.target; + if (!value || value == scr_builtin_object_prototype) return false; + if (value->kind == SCR_DYN_FUNC && value->v.fn.class_obj && !value->v.fn.class_obj->static_data) return false; + return scr_dyn_frozen_answer(value, scr_dyn_checked_record_frozen, false); +} + double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_byte_length_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_byte_len(value->v.bytes); diff --git a/tests/corpus/frozen-checked-records-sloppy.cjs b/tests/corpus/frozen-checked-records-sloppy.cjs new file mode 100644 index 000000000..f6edab231 --- /dev/null +++ b/tests/corpus/frozen-checked-records-sloppy.cjs @@ -0,0 +1,37 @@ +// Untyped values frozen in sloppy-mode code and then held as typed records +// stay frozen: writes through typed parameters and a typed `this` leave them +// unchanged, as Node does, and Object.isFrozen answers true. An unfrozen value +// checked into the same record type stays writable. + +/** @typedef {{ count: number, label: string }} Counter */ + +/** @param {Counter} c @param {number} by */ +function bump(c, by) { + c.count += by; + c.label = c.label + "!"; + return c.count; +} + +/** @this {Counter} @param {number} by */ +function bumpThis(by) { + this.count += by; + this.label = "this"; + return this.count; +} + +/** @type {Counter} */ +const literal = Object.freeze({ count: 1, label: "a" }); +console.log(bump(literal, 2), bumpThis.call(literal, 3), JSON.stringify(literal), Object.isFrozen(literal)); + +/** @type {Counter} */ +const parsed = Object.freeze(JSON.parse('{"count":10,"label":"b"}')); +console.log(bump(parsed, 2), bumpThis.call(parsed, 3), JSON.stringify(parsed), Object.isFrozen(parsed)); + +/** @type {Counter} */ +const open = JSON.parse('{"count":100,"label":"c"}'); +console.log(bump(open, 2), bumpThis.call(open, 3), JSON.stringify(open), Object.isFrozen(open)); + +/** @type {Counter[]} */ +const list = [Object.freeze({ count: 5, label: "l" }), { count: 6, label: "m" }]; +for (const item of list) bump(item, 1); +console.log(JSON.stringify(list), Object.isFrozen(list[0]), Object.isFrozen(list[1])); diff --git a/tests/corpus/frozen-checked-records.ts b/tests/corpus/frozen-checked-records.ts new file mode 100644 index 000000000..1c205079e --- /dev/null +++ b/tests/corpus/frozen-checked-records.ts @@ -0,0 +1,29 @@ +// An untyped value frozen and then held as a typed record stays frozen: +// writes to it in strict mode code throw Node's TypeError and leave it +// unchanged. An unfrozen value checked into the same record type stays +// writable. + +type Counter = { count: number; label: string }; + +function bump(c: Counter, by: number): string { + try { + c.count += by; + return "wrote " + c.count; + } catch (e) { + return e instanceof TypeError ? "TypeError" : "other"; + } +} + +const frozen = Object.freeze(JSON.parse('{"count":1,"label":"a"}')) as Counter; +console.log(bump(frozen, 2), frozen.count, Object.isFrozen(frozen)); + +const open = JSON.parse('{"count":10,"label":"b"}') as Counter; +console.log(bump(open, 2), open.count, Object.isFrozen(open)); + +const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}')); +const viaUnknown = raw as Counter; +console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown)); + +// A proxy checked into the record type stays writable. +const proxied = new Proxy({ count: 40, label: "q" }, {}) as Counter; +console.log(bump(proxied, 2), proxied.count); diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs new file mode 100644 index 000000000..1fef1859a --- /dev/null +++ b/tests/corpus/sloppy-this-global.cjs @@ -0,0 +1,52 @@ +// In sloppy-mode functions, `this` is the global object when the function is +// called with undefined or null as its receiver: by a plain call, by a +// detached method, and through call and apply. Strict functions and arrows +// keep the receiver they get. + +// Before anything else reads the global object. +function readConsole() { + return typeof this.console.log === "function" && this.console === console; +} +console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); + +function receiver() { + return this; +} +function kind() { + return typeof this; +} +function strictReceiver() { + "use strict"; + return this; +} + +console.log(receiver() === globalThis, kind()); +console.log(receiver.call(undefined) === globalThis, receiver.call(null) === globalThis); +console.log(receiver.apply(undefined, []) === globalThis, receiver.apply(null) === globalThis); +console.log(strictReceiver() === undefined, strictReceiver.call(null) === null); + +const holder = { + name: "holder", + self() { + return this; + }, +}; +const detached = holder.self; +console.log(holder.self() === holder, detached() === globalThis); + +function outer() { + function inner() { + return this; + } + return inner() === globalThis; +} +console.log(outer(), outer.call({ name: "o" })); + +globalThis.sloppyMarker = 42; +function readMarker() { + return this.sloppyMarker; +} +console.log(readMarker(), readMarker.call(undefined), readMarker.call({ sloppyMarker: 7 })); + +const arrow = () => this; +console.log(arrow() === module.exports, arrow.call(null) === module.exports); diff --git a/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..6289d836d --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1 @@ +{ "plain": 3590, "san": 12400 } diff --git a/tests/harness/corpus-costs/frozen-checked-records.ts.json b/tests/harness/corpus-costs/frozen-checked-records.ts.json new file mode 100644 index 000000000..1086c8461 --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records.ts.json @@ -0,0 +1 @@ +{ "plain": 2610, "san": 12260 } diff --git a/tests/harness/corpus-costs/sloppy-this-global.cjs.json b/tests/harness/corpus-costs/sloppy-this-global.cjs.json new file mode 100644 index 000000000..66f469d88 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global.cjs.json @@ -0,0 +1 @@ +{ "plain": 3030, "san": 3970 } From efdd53912d88fdaee00c21e3d33c328abb24926f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 23:43:34 -0500 Subject: [PATCH 13/47] Answer builtin globals read through a sloppy-mode function's this A sloppy-mode function called with undefined or null as this reads the global object, which answers builtin globals through the program's lookup. Modules whose sloppy-mode functions read builtin globals through this, or through a variable it initializes, now install that lookup; it answers those names, or every declared global when a module reads the global object by computed names. --- .../src/frontend/lowering/global-values.ts | 116 ++++++++++++++++-- .../src/frontend/lowering/lower-modules.ts | 10 +- .../compiler/src/frontend/lowering/lowerer.ts | 8 ++ tests/corpus/sloppy-this-global.cjs | 10 ++ 4 files changed, 128 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 21593203d..6fc3cb694 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -24,6 +24,7 @@ import { type SrcLoc, } from "../../ir/ir.js"; import { isJsSourceFile } from "../program.js"; +import { isStrictCode } from "./strict-mode.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; import { stdlibGlobalNameOf } from "./surfaces.js"; @@ -166,22 +167,115 @@ const URI_FUNCTIONS = new Set([ "decodeURIComponent", ]); +/** The builtin globals that sloppy-mode functions of the JavaScript source + * `sf` read through their `this` (`this.Array`) or through a variable it + * initializes (`var root = this; root.Array`). Called with undefined or null + * as `this`, such a function reads them on the global object, which answers + * them through the program's lookup. */ +export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): ReadonlySet { + const cached = lowerer.sloppyThisGlobalReads.get(sf); + if (cached !== undefined) return cached; + const reads = new Set(); + if (isJsSourceFile(sf)) { + const declared = new Set(declaredGlobalNames(lowerer, sf)); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.name) && + declared.has(node.name.text) && + readsSloppyThis(lowerer, node.expression) + ) + reads.add(node.name.text); + return undefined; + }); + } + lowerer.sloppyThisGlobalReads.set(sf, reads); + return reads; +} + +/** Whether `expr` is the `this` of a sloppy-mode function, or a variable + * whose only declaration initializes it with that `this`. */ +function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { + let inner = expr; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + if (inner.kind === ts.SyntaxKind.ThisKeyword) return sloppyReceiverOf(inner); + if (!ts.isIdentifier(inner)) return false; + const declarations = lowerer.checker.getSymbolAtLocation(inner)?.declarations ?? []; + const declaration = declarations[0]; + if ( + declarations.length !== 1 || + declaration === undefined || + !ts.isVariableDeclaration(declaration) + ) + return false; + let init = declaration.initializer; + while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + return init !== undefined && init.kind === ts.SyntaxKind.ThisKeyword && sloppyReceiverOf(init); +} + +/** Whether the `this` at `node` is a sloppy-mode function's receiver + * (lower-functions.ts declareCallReceiver): arrows take the enclosing one. */ +function sloppyReceiverOf(node: ts.Node): boolean { + for (let parent = node.parent; parent !== undefined; parent = parent.parent) { + if (ts.isArrowFunction(parent)) continue; + if (ts.isFunctionLike(parent)) return parent.body !== undefined && !isStrictCode(parent.body); + if ( + ts.isSourceFile(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isClassStaticBlockDeclaration(parent) + ) + return false; + } + return false; +} + +/** The names of the global object's declared properties that the lookup can + * answer. */ +function declaredGlobalNames(lowerer: Lowerer, at: ts.Node): string[] { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(at), + false, + ); + if (symbol === undefined) return []; + return lowerer.checker + .getPropertiesOfType(lowerer.checker.getTypeOfSymbol(symbol)) + .map((property) => ts.symbolName(property)) + .filter((name) => name !== TEMPORAL_GLOBAL && !RUNTIME_ANSWERED.has(name)); +} + +/** The names the program's lookup answers: every declared global when a + * module reads the global object by computed names, else the ones its + * sloppy-mode functions read through `this`. */ +function lookupNames(lowerer: Lowerer): readonly string[] { + if (lowerer.globalLookupNames !== undefined) return lowerer.globalLookupNames; + const modules = lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]; + const names = modules.some((sf) => computedGlobalReadOf(lowerer, sf) !== null) + ? declaredGlobalNames(lowerer, lowerer.entry) + : modules.flatMap((sf) => [...sloppyThisGlobalReadsOf(lowerer, sf)]); + lowerer.globalLookupNames = [...new Set(names)].sort(); + return lowerer.globalLookupNames; +} + +/** Whether the JavaScript source `sf` installs the program's lookup when its + * body starts: it reads builtin globals through the global object by + * computed names, or through a sloppy-mode function's `this`. */ +export function installsGlobalValues(lowerer: Lowerer, sf: ts.SourceFile): boolean { + return ( + computedGlobalReadOf(lowerer, sf) !== null || sloppyThisGlobalReadsOf(lowerer, sf).size > 0 + ); +} + /** The statement that installs the program's lookup, built on first use - * from the names the checker declares on `receiver` (a global object). */ -export function globalValuesInstall( - lowerer: Lowerer, - receiver: ts.Expression, - loc: SrcLoc, -): IrStmt { + * from the names it answers (lookupNames). */ +export function globalValuesInstall(lowerer: Lowerer, loc: SrcLoc): IrStmt { const fnType: IrType & { kind: "func" } = { kind: "func", params: [STRING], ret: DYN }; if (!lowerer.liftedFnNamed(LOOKUP_FN)) { - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(receiver)) - .map((property) => ts.symbolName(property)) - .filter((name) => name !== TEMPORAL_GLOBAL && !RUNTIME_ANSWERED.has(name)); const name: IrExpr = { kind: "varRef", localId: "name.0", type: STRING, loc }; const body: IrStmt[] = []; - for (const global of [...new Set(names)].sort()) { + for (const global of lookupNames(lowerer)) { const webStream = (WEB_STREAM_CLASSES as readonly string[]).indexOf(global); const value: IrExpr | null = REFUSED.has(global) ? { kind: "unitLit", unit: "null", type: { kind: "nullT" }, loc } diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 9804aebdf..466f80ac8 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,4 +1,4 @@ -import { computedGlobalReadOf, globalValuesInstall } from "./global-values.js"; +import { globalValuesInstall, installsGlobalValues } from "./global-values.js"; import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { THREADS_MODULE } from "../threads-import.js"; import { collectIslandImport } from "./lower-node-island.js"; @@ -3120,10 +3120,10 @@ export function lowerFileInit( }); } if (sf === lowerer.entry) header.push(...lowerer.punycodeLinkWarning()); - // A module that reads builtin globals through the global object by - // computed names installs the program's lookup before its body runs. - const computedGlobalRead = computedGlobalReadOf(lowerer, sf); - if (computedGlobalRead) header.push(globalValuesInstall(lowerer, computedGlobalRead, loc0)); + // A module that reads builtin globals through the global object, by + // computed names or through a sloppy-mode function's `this`, installs + // the program's lookup before its body runs. + if (installsGlobalValues(lowerer, sf)) header.push(globalValuesInstall(lowerer, loc0)); // Top-level `this` of a CommonJS module: its object exists before the // module's first statement (lowerCjsTopLevelThis). const exportsThis = lowerer.cjsExportsThis.get(sf); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 58e5d2ba5..a00d152b0 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3752,6 +3752,12 @@ export class Lowerer { /** global-values.ts's answer per JavaScript source: the receiver of its * first read of the global object by a computed name, or null. */ readonly computedGlobalReads = new Map(); + /** global-values.ts's answer per JavaScript source: the builtin globals + * its sloppy-mode functions read through their `this`. */ + readonly sloppyThisGlobalReads = new Map>(); + /** global-values.ts's answer: the names the program's global lookup + * answers; undefined until asked. */ + globalLookupNames: readonly string[] | undefined = undefined; /** web-streams-dyn.ts's answer: whether a JavaScript source names a web * stream class; undefined until asked. */ namesWebStreamClass: boolean | undefined = undefined; @@ -4378,6 +4384,8 @@ export class Lowerer { "createRequireBindingTargets", "createRequireElementTargets", "computedGlobalReads", + "sloppyThisGlobalReads", + "globalLookupNames", "namesWebStreamClass", "timeValueDateDecisions", "timeValueDateWrites", diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs index 1fef1859a..dfca51b30 100644 --- a/tests/corpus/sloppy-this-global.cjs +++ b/tests/corpus/sloppy-this-global.cjs @@ -9,6 +9,16 @@ function readConsole() { } console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); +// Builtins read through the global `this`, directly and through a variable. +function readBuiltins() { + return [this.Array === Array, this.JSON === JSON, typeof this.setTimeout]; +} +function aliasedRoot() { + var root = this; + return root.Object === Object && typeof root.Promise; +} +console.log(readBuiltins(), aliasedRoot()); + function receiver() { return this; } From 0f47b9cbabe1bd0a6a40a27ae4795604bb5fac4f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 06:05:39 -0500 Subject: [PATCH 14/47] Answer builtin globals read through a sloppy-mode function's this by key A sloppy-mode function called with undefined or null as this reads the global object. Element reads of that this, or of a variable it initializes, now reach the program's lookup too: a literal key (`this["Map"]`) adds that name, and a computed key (`this[name]`) installs the lookup for every declared global, as `globalThis[name]` does. Before, a literal key read the opaque identity token and a computed key threw SC2020. --- .../src/frontend/lowering/global-values.ts | 51 ++++++++++++++++--- .../sloppy-this-global-computed.cjs.json | 6 +++ tests/corpus/sloppy-this-global-computed.cjs | 26 ++++++++++ tests/corpus/sloppy-this-global.cjs | 7 +++ .../sloppy-this-global-computed.cjs.json | 1 + 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json create mode 100644 tests/corpus/sloppy-this-global-computed.cjs create mode 100644 tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 6fc3cb694..af52fe94e 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -168,16 +168,22 @@ const URI_FUNCTIONS = new Set([ ]); /** The builtin globals that sloppy-mode functions of the JavaScript source - * `sf` read through their `this` (`this.Array`) or through a variable it - * initializes (`var root = this; root.Array`). Called with undefined or null - * as `this`, such a function reads them on the global object, which answers - * them through the program's lookup. */ + * `sf` read through their `this` (`this.Array`, `this["Array"]`) or through + * a variable it initializes (`var root = this; root.Array`); every declared + * global when such a read takes a computed key (`this[name]`). Called with + * undefined or null as `this`, such a function reads them on the global + * object, which answers them through the program's lookup. */ export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): ReadonlySet { const cached = lowerer.sloppyThisGlobalReads.get(sf); if (cached !== undefined) return cached; const reads = new Set(); if (isJsSourceFile(sf)) { const declared = new Set(declaredGlobalNames(lowerer, sf)); + // Element reads of `this` or of a variable, and the names of the + // variables initialized with `this`: the checker resolves only the + // element reads of `this` and of those variables. + const elementReads: ts.ElementAccessExpression[] = []; + const thisAliases = new Set(); ts.walkPreorder(sf, (node) => { if ( ts.isPropertyAccessExpression(node) && @@ -186,8 +192,32 @@ export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): Re readsSloppyThis(lowerer, node.expression) ) reads.add(node.name.text); + else if (ts.isElementAccessExpression(node)) { + const target = withoutParentheses(node.expression); + if (target.kind === ts.SyntaxKind.ThisKeyword || ts.isIdentifier(target)) + elementReads.push(node); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer !== undefined && + withoutParentheses(node.initializer).kind === ts.SyntaxKind.ThisKeyword + ) + thisAliases.add(node.name.text); return undefined; }); + for (const node of elementReads) { + const target = withoutParentheses(node.expression); + if (ts.isIdentifier(target) && !thisAliases.has(target.text)) continue; + if (!readsSloppyThis(lowerer, node.expression) || !readsBuiltinByName(lowerer, node)) + continue; + const key = withoutParentheses(node.argumentExpression); + if (ts.isStringLiteralLike(key)) { + if (declared.has(key.text)) reads.add(key.text); + } else if (!ts.isNumericLiteral(key)) { + for (const name of declared) reads.add(name); + break; + } + } } lowerer.sloppyThisGlobalReads.set(sf, reads); return reads; @@ -196,8 +226,7 @@ export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): Re /** Whether `expr` is the `this` of a sloppy-mode function, or a variable * whose only declaration initializes it with that `this`. */ function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { - let inner = expr; - while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + const inner = withoutParentheses(expr); if (inner.kind === ts.SyntaxKind.ThisKeyword) return sloppyReceiverOf(inner); if (!ts.isIdentifier(inner)) return false; const declarations = lowerer.checker.getSymbolAtLocation(inner)?.declarations ?? []; @@ -208,11 +237,17 @@ function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { !ts.isVariableDeclaration(declaration) ) return false; - let init = declaration.initializer; - while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + const init = + declaration.initializer === undefined ? undefined : withoutParentheses(declaration.initializer); return init !== undefined && init.kind === ts.SyntaxKind.ThisKeyword && sloppyReceiverOf(init); } +function withoutParentheses(expr: ts.Expression): ts.Expression { + let inner = expr; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + return inner; +} + /** Whether the `this` at `node` is a sloppy-mode function's receiver * (lower-functions.ts declareCallReceiver): arrows take the enclosing one. */ function sloppyReceiverOf(node: ts.Node): boolean { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json new file mode 100644 index 000000000..ba45eb398 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global-computed.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/sloppy-this-global-computed.cjs b/tests/corpus/sloppy-this-global-computed.cjs new file mode 100644 index 000000000..ac526e086 --- /dev/null +++ b/tests/corpus/sloppy-this-global-computed.cjs @@ -0,0 +1,26 @@ +// A sloppy-mode function called with undefined or null as its receiver reads +// builtin globals on the global object by computed keys, directly and through +// a variable its `this` initializes. + +function readByKey(key) { + return this[key]; +} +function aliasedRead(key) { + var root = this; + return root[key]; +} + +console.log(readByKey("Array") === Array, readByKey("JSON") === JSON); +console.log(aliasedRead("Map") === Map, typeof aliasedRead("setTimeout")); +console.log(readByKey.call(null, "Promise") === Promise, readByKey.call({ Promise: 1 }, "Promise")); +console.log(readByKey("globalThis") === globalThis, readByKey("missingGlobal")); + +const names = ["Object", "Symbol", "Uint8Array"]; +const values = [Object, Symbol, Uint8Array]; +console.log(names.map((name, i) => readByKey(name) === values[i])); + +function strictRead(key) { + "use strict"; + return this === undefined ? "no receiver" : this[key]; +} +console.log(strictRead("Array"), strictRead.call({ Array: 2 }, "Array")); diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs index dfca51b30..ca7f212db 100644 --- a/tests/corpus/sloppy-this-global.cjs +++ b/tests/corpus/sloppy-this-global.cjs @@ -19,6 +19,13 @@ function aliasedRoot() { } console.log(readBuiltins(), aliasedRoot()); +// Builtins read through the global `this` by literal keys. +function readByLiteral() { + var root = this; + return [this["Map"] === Map, root["Math"] === Math, typeof this[`Set`], this[0]]; +} +console.log(readByLiteral(), readByLiteral.call({ Map: 1 })[0]); + function receiver() { return this; } diff --git a/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json b/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json new file mode 100644 index 000000000..2249e0825 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 4550 } From 6f65b58432e455fd38a95bbaec849345a10846bf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 07:14:31 -0500 Subject: [PATCH 15/47] Report a namespace's member fences where the program names it The program's builtin lookup holds every declared global when a module reads the global object, or a sloppy-mode function's this, by a computed key. Building the Reflect and JSON namespaces for it reported the SC1090 fence of each member without a function value (Reflect.apply, JSON.rawJSON, ...), so a program that never names those namespaces reported them. A namespace's builder now only creates the functions that throw those fences when called, and builtinNamespaceValue reports the fences, once per program, when a site that names the namespace reads it. The lookup reads the namespaces without reporting; its members still throw their fences when called. --- .../frontend/lowering/builtin-namespaces.ts | 44 ++++++++++++++----- .../src/frontend/lowering/global-values.ts | 14 ++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts index c1e40d2e6..07c6bda5b 100644 --- a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts @@ -19,7 +19,7 @@ import { funcOf, } from "../../ir/ir.js"; import { helperLoc, varRef } from "../../ir/build.js"; -import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { unsupportedDiag, type ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import type { Lowerer } from "./lowerer.js"; import { InternalCompilerError } from "../../errors.js"; import { mathMemberValue } from "./lower-stdlib.js"; @@ -117,21 +117,46 @@ const WRITABLE = 1; const CONFIGURABLE = 4; /** The namespace object the stdlib global `canonical` reads as in - * JavaScript, or null when it is not a namespace. */ + * JavaScript, or null when it is not a namespace. The fences of its members + * without a function value are reported unless `reportFences` is false: the + * program's builtin lookup (global-values.ts) holds every namespace without + * the program naming it, and its members still throw their fences when + * called. */ export function builtinNamespaceValue( lowerer: Lowerer, canonical: string, loc: SrcLoc, + reportFences = true, ): IrExpr | null { const members = namespaceMembers(canonical); if (members === null) return null; const name = `%builtin.namespace.${canonical}`; if (!lowerer.liftedFns.some((fn) => fn.name === name)) defineNamespaceBuilder(lowerer, canonical, members, name, helperLoc(loc)); + if (reportFences) reportMemberFences(lowerer, canonical, members, helperLoc(loc)); lowerer.noteEdge(name); return { kind: "call", callee: name, args: [], type: DYN, loc }; } +/** Reports the SC1090 fence of each member of `canonical` whose function + * throws it (fencedMember), once per program. */ +function reportMemberFences( + lowerer: Lowerer, + canonical: string, + members: readonly NamespaceMember[], + loc: SrcLoc, +): void { + for (const { key, length } of members) { + if (length < 0) continue; + const display = `${canonical}.${key}`; + if (!lowerer.liftedFns.some((fn) => fn.name === fenceName(display))) continue; + const diag = fenceDiag(display, loc); + if (lowerer.runtimeFences.some((d) => d.code === diag.code && d.message === diag.message)) + continue; + lowerer.runtimeFences.push(diag); + } +} + /** The lifted function that builds the namespace object once and answers * the same object afterwards. */ function defineNamespaceBuilder( @@ -253,18 +278,17 @@ function functionValue(lowerer: Lowerer, canonical: string, key: string, loc: Sr return fencedMember(lowerer, `${canonical}.${key}`, key, loc); } +const fenceName = (display: string): string => `%builtin.fence.${display}`; +const fenceDiag = (display: string, loc: SrcLoc): ScrDiagnostic => + unsupportedDiag("SC1090", loc, `'${display}' as a function value (call it directly)`); + /** One function per member without a function value: calling it throws the * SC1090 fence, while `typeof`, identity and the property's presence stay - * as in Node. */ + * as in Node. builtinNamespaceValue reports the fence. */ function fencedMember(lowerer: Lowerer, display: string, key: string, loc: SrcLoc): IrExpr { - const name = `%builtin.fence.${display}`; + const name = fenceName(display); if (!lowerer.liftedFns.some((fn) => fn.name === name)) { - const diag = unsupportedDiag( - "SC1090", - loc, - `'${display}' as a function value (call it directly)`, - ); - lowerer.runtimeFences.push(diag); + const diag = fenceDiag(display, loc); lowerer.liftedFns.push({ name, params: [], diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index af52fe94e..84bb587d1 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -122,11 +122,17 @@ function readsBuiltinByName(lowerer: Lowerer, node: ts.ElementAccessExpression): /** The value a JavaScript source reads for the declared global `name` * taken as a value, whatever its spelling (`name`, `globalThis.name`, * `const { name } = globalThis`, `globalThis[name]`), or null when it - * reads the opaque identity token. */ -export function jsGlobalValueByName(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { + * reads the opaque identity token. `reportFences` (builtinNamespaceValue) + * is false for the program's lookup. */ +export function jsGlobalValueByName( + lowerer: Lowerer, + name: string, + loc: SrcLoc, + reportFences = true, +): IrExpr | null { return ( jsSpecialGlobalValue(lowerer, name, loc) ?? - builtinNamespaceValue(lowerer, name, loc) ?? + builtinNamespaceValue(lowerer, name, loc, reportFences) ?? builtinGlobalValue(name, loc, true) ); } @@ -318,7 +324,7 @@ export function globalValuesInstall(lowerer: Lowerer, loc: SrcLoc): IrStmt { ? programNamesWebStreamClass(lowerer) ? webStreamClassValue(webStream, loc) : null - : jsGlobalValueByName(lowerer, global, loc); + : jsGlobalValueByName(lowerer, global, loc, false); if (value === null) continue; body.push({ kind: "if", From 500dd3e9b4a80124260244044d22c7cd60f4e78f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 18:52:05 -0500 Subject: [PATCH 16/47] Run short constant-count loops together when their inner exits saturate A loop of two to eight iterations with a constant count, whose body is an inner loop that exits once an accumulator reaches the value its ORs saturate at (mandelbrot's pixel pairs: `b |= 2`, `b |= 1`, `if (b === 3) break`), now runs its iterations in one inner loop: every iteration's declarations, then one loop running each iteration's body in turn that exits once every accumulator is full, then every iteration's trailing assignments in order. Once an accumulator is full its ORs leave it full, so an iteration that runs past its own exit leaves its accumulator as its own loop did, and every other value it computes is dead. The bodies only compute numbers, read typed-array elements that answer NaN out of range and write their own locals; the declarations and the inner loop read nothing the trailing assignments write. The independent iterations then overlap their dependence chains. --- packages/compiler/src/backend/llvm/emitter.ts | 23 +- .../src/ir/saturating-loop-jams.test.ts | 188 ++++++++ .../compiler/src/ir/saturating-loop-jams.ts | 424 ++++++++++++++++++ .../tests/corpus/saturating-loop-jams.ts.json | 6 + tests/corpus/saturating-loop-jams.ts | 85 ++++ .../corpus-costs/saturating-loop-jams.ts.json | 1 + 6 files changed, 717 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/src/ir/saturating-loop-jams.test.ts create mode 100644 packages/compiler/src/ir/saturating-loop-jams.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json create mode 100644 tests/corpus/saturating-loop-jams.ts create mode 100644 tests/harness/corpus-costs/saturating-loop-jams.ts.json diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9d85bb8da..eeb418512 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -96,6 +96,7 @@ import { } from "../../ir/inline-arrays.js"; import { inlineArraysApply, programLifetimeClassOf } from "./inline-arrays.js"; import { pairRowSums } from "../../ir/row-pairs.js"; +import { jamSaturatingLoops } from "../../ir/saturating-loop-jams.js"; import { allocatedLiteral, fillAllocationLength, @@ -759,16 +760,18 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { specializeCallbackCalls( specializeNumericCalls( pairRowSums( - inlineArraysPass( - options, - inlineExpressionFunctions( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + jamSaturatingLoops( + inlineArraysPass( + options, + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + ), ), ), ), diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts new file mode 100644 index 000000000..b3d1d0fe9 --- /dev/null +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { everyStmtList } from "./traverse.js"; +import { jamSaturatingLoops } from "./saturating-loop-jams.js"; + +const loc = { file: "saturating-loop-jams.ts", start: 0, end: 0 }; +const BYTES = { kind: "bytes", elem: "f64" } as const; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +type Op = "+" | "*" | "-" | "<" | ">" | "|" | "===" | "<<"; +const bin = (op: Op, left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "bin", + op, + left, + right, + type: op === "<" || op === ">" || op === "===" ? BOOL : F64, +}); +const decl = (id: string, init: IrExpr): IrStmt => ({ loc, kind: "varDecl", localId: id, init }); +const set = (id: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: id, value }); +const stepBy = (id: string, by: number): IrStmt => set(id, bin("+", ref(id), num(by))); +const loop = (id: string, end: IrExpr, by: number, body: IrStmt[]): IrStmt & { kind: "for" } => ({ + loc, + kind: "for", + init: decl(id, num(0)), + cond: bin("<", ref(id), end), + update: stepBy(id, by), + body, +}); +const exitWhen = (acc: string, full: number): IrStmt => ({ + loc, + kind: "if", + cond: bin("===", ref(acc), num(full)), + then: [{ loc, kind: "break" }], + else_: null, +}); +const get = (index: IrExpr): IrExpr => ({ + loc, + kind: "bytesIntrinsic", + method: "get", + receiver: { loc, kind: "varRef", localId: "cr", type: BYTES }, + args: [index], + type: F64, + invalidNaN: true, +}); + +/** One lane of mandelbrot's escape test: `z = z * z + c`, `b |= bit` and + * an exit once `b` is full. */ +function lane(z: string, c: string, bit: number, full = 3, extra: IrStmt[] = []): IrStmt[] { + return [ + decl(`${z}.t`, bin("*", ref(z), ref(z))), + set(z, bin("+", ref(`${z}.t`), ref(c))), + { + loc, + kind: "if", + cond: bin(">", ref(`${z}.t`), num(4)), + then: [set("b", bin("|", ref("b"), num(bit))), exitWhen("b", full), ...extra], + else_: null, + }, + ]; +} + +function nest( + options: { full?: number; post?: IrStmt[]; preExtra?: IrStmt[]; copies?: number } = {}, +): IrStmt[] { + const post = options.post ?? [set("res", bin("|", bin("<<", ref("res"), num(2)), ref("b")))]; + return [ + decl("res", num(0)), + loop("i", num(2 * (options.copies ?? 4)), 2, [ + decl("c1", get(ref("i"))), + decl("c2", get(bin("+", ref("i"), num(1)))), + decl("z1", ref("c1")), + decl("z2", ref("c2")), + decl("b", num(0)), + ...(options.preExtra ?? []), + loop("j", num(50), 1, [ + ...lane("z1", "c1", 2, options.full), + ...lane("z2", "c2", 1, options.full), + ]), + ...post, + ]), + ]; +} + +function moduleOf(body: IrStmt[]): IrModule { + const ids = new Set(); + everyStmtList(body, { + expr: (e) => { + if (e.kind === "varRef" && e.localId !== "cr") ids.add(e.localId); + return true; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") ids.add(s.localId); + return true; + }, + }); + const fn: IrFunction = { + name: "render", + params: [{ localId: "cr", name: "cr", type: BYTES }], + locals: [ + { id: "cr", name: "cr", type: BYTES, mutable: false }, + ...[...ids].map((id) => ({ id, name: id, type: F64, mutable: true })), + ], + body, + returnType: VOID, + loc, + }; + return { irVersion: 17, sourceFile: loc.file, entry: "render", functions: [fn] } as IrModule; +} + +function loops(body: IrStmt[]): (IrStmt & { kind: "for" })[] { + const found: (IrStmt & { kind: "for" })[] = []; + everyStmtList(body, { + expr: () => true, + stmt: (s) => { + if (s.kind === "for") found.push(s); + return true; + }, + }); + return found; +} + +test("jams a constant-count loop whose inner loop exits once its accumulator is full", () => { + const mod = moduleOf(nest()); + const out = jamSaturatingLoops(mod); + expect(out).not.toBe(mod); + const body = out.functions[0]!.body; + // The outer loop is gone: one inner loop runs the four iterations. + const remaining = loops(body); + expect(remaining.length).toBe(1); + const inner = remaining[0]!; + // Its only exit tests every copy's accumulator, at the end of the body. + let breaks = 0; + everyStmtList(inner.body, { + expr: () => true, + stmt: (s) => { + if (s.kind === "break") breaks++; + return true; + }, + }); + 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. + const added = out.functions[0]!.locals.filter((local) => local.id.startsWith("%jam.")); + expect(added.filter((local) => local.name === "%jam.b").length).toBe(4); + const block = body[1]!; + expect(block.kind).toBe("block"); + if (block.kind !== "block") return; + const posts = block.body.slice(-4); + expect(posts.every((s) => s.kind === "assign" && s.localId === "res")).toBe(true); +}); + +test("keeps loops whose exits or values do not allow running iterations together", () => { + const reject = (body: IrStmt[]): void => { + const mod = moduleOf(body); + expect(jamSaturatingLoops(mod)).toBe(mod); + }; + // An exit before the accumulator is full: later ORs would change it. + reject(nest({ full: 2 })); + // `post` reads a lane value the copies keep computing past their exit. + reject(nest({ post: [set("res", bin("+", ref("res"), ref("z1")))] })); + // `pre` reads what `post` writes. + reject(nest({ preExtra: [decl("seed", ref("res"))] })); + // One iteration, or more than eight. + reject(nest({ copies: 1 })); + reject(nest({ copies: 9 })); + // A store in the inner loop is observable past an exit. + reject([ + decl("res", num(0)), + loop("i", num(8), 2, [ + decl("b", num(0)), + decl("z1", num(0)), + loop("j", num(50), 1, [ + ...lane("z1", "z1", 2, 3, [ + { + loc, + kind: "bytesSet", + arr: { loc, kind: "varRef", localId: "cr", type: BYTES }, + index: num(0), + value: num(1), + }, + ]), + ]), + set("res", bin("|", ref("res"), ref("b"))), + ]), + ]); +}); diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts new file mode 100644 index 000000000..ae4bd38c8 --- /dev/null +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -0,0 +1,424 @@ +import { + BOOL, + F64, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { everyStmtList, transformStmtList } from "./traverse.js"; + +/** The most iterations of an outer loop that run jammed. */ +const MAX_COPIES = 8; + +/** The most statement and expression nodes of one outer iteration: every + * copy duplicates it. */ +const MAX_BODY_NODES = 160; + +/** Binary operators of numbers and booleans that run no other code and + * cannot throw. */ +const PURE_BINARY = new Set([ + "+", + "-", + "*", + "/", + "%", + "&", + "|", + "^", + "<<", + ">>", + ">>>", + "<", + "<=", + ">", + ">=", + "===", + "!==", +]); + +const PURE_UNARY = new Set(["-", "+", "~", "!"]); + +interface Jam { + counter: string; + /** The counter's value on each iteration. */ + values: number[]; + pre: IrStmt[]; + inner: IrStmt & { kind: "for" }; + post: IrStmt[]; + /** The accumulator the inner loop's exits test, and its full value. */ + acc: string; + full: number; +} + +function isRef(e: IrExpr | null | undefined, id: string): boolean { + return e?.kind === "varRef" && e.localId === id; +} + +function intLit(e: IrExpr | null | undefined): number | null { + return e?.kind === "numLit" && Number.isSafeInteger(e.value) && !Object.is(e.value, -0) + ? e.value + : null; +} + +/** `id = id + step` with a positive integer literal step. */ +function constantStep(update: IrStmt | null, id: string): number | null { + if ( + update?.kind !== "assign" || + update.localId !== id || + update.value.kind !== "bin" || + update.value.op !== "+" || + !isRef(update.value.left, id) + ) + return null; + const step = intLit(update.value.right); + return step !== null && step > 0 ? step : null; +} + +/** A number or boolean expression that runs no other code and cannot throw: + * literals, reads of locals and module bindings, arithmetic, comparisons + * and typed-array element reads that answer NaN out of range. */ +function pureExpr(e: IrExpr, locals: ReadonlyMap): boolean { + if (e.type.kind !== "f64" && e.type.kind !== "bool") return false; + switch (e.kind) { + case "numLit": + case "boolLit": + return true; + case "varRef": { + const local = locals.get(e.localId); + if (local) return !local.boxed && !local.tdz; + return e.localId.startsWith("%g."); + } + case "bin": + return PURE_BINARY.has(e.op) && pureExpr(e.left, locals) && pureExpr(e.right, locals); + case "unary": + return PURE_UNARY.has(e.op) && pureExpr(e.operand, locals); + case "bytesIntrinsic": + return ( + e.method === "get" && + e.invalidNaN === true && + e.receiver.kind === "varRef" && + e.args.length === 1 && + pureExpr(e.args[0]!, locals) + ); + default: + return false; + } +} + +/** A local written by `s` (a declaration or assignment), or null. */ +function writtenLocal(s: IrStmt): string | null { + return s.kind === "varDecl" || s.kind === "assign" ? s.localId : null; +} + +/** Straight-line declarations and assignments of pure values. */ +function pureStatements(list: readonly IrStmt[], locals: ReadonlyMap): boolean { + return list.every( + (s) => + (s.kind === "varDecl" && s.init !== null && pureExpr(s.init, locals)) || + (s.kind === "assign" && locals.has(s.localId) && pureExpr(s.value, locals)), + ); +} + +/** The statements of the inner loop's body: pure declarations and + * assignments, `if` statements over them, `acc = acc | C`, and + * `if (acc === full) break` (collected into `breaks`). */ +function innerStatements( + list: readonly IrStmt[], + locals: ReadonlyMap, + acc: { id: string | null; bits: number; breaks: number[] }, +): boolean { + return list.every((s) => { + if (s.kind === "if") { + // `if (acc === full) break` + if ( + s.then.length === 1 && + s.then[0]!.kind === "break" && + s.then[0]!.label === undefined && + (s.else_ === null || s.else_.length === 0) && + s.cond.kind === "bin" && + s.cond.op === "===" && + s.cond.left.kind === "varRef" && + intLit(s.cond.right) !== null + ) { + if (acc.id !== null && acc.id !== s.cond.left.localId) return false; + acc.id = s.cond.left.localId; + acc.breaks.push(intLit(s.cond.right)!); + return true; + } + return ( + pureExpr(s.cond, locals) && + innerStatements(s.then, locals, acc) && + innerStatements(s.else_ ?? [], locals, acc) + ); + } + if ( + s.kind === "assign" && + s.value.kind === "bin" && + s.value.op === "|" && + isRef(s.value.left, s.localId) + ) { + const bits = intLit(s.value.right); + if (bits !== null && bits >= 0 && bits <= 0x7fffffff) { + if (acc.id !== null && acc.id !== s.localId) return false; + acc.id = s.localId; + acc.bits |= bits; + return true; + } + } + return pureStatements([s], locals); + }); +} + +function countNodes(list: readonly IrStmt[]): number { + let n = 0; + everyStmtList(list as IrStmt[], { + expr: () => ++n <= MAX_BODY_NODES, + stmt: () => ++n <= MAX_BODY_NODES, + }); + return n; +} + +/** Reads of local `id` anywhere in `list`. */ +function reads(list: readonly IrStmt[], id: string): boolean { + return !everyStmtList(list as IrStmt[], { expr: (e) => !isRef(e, id), stmt: () => true }); +} + +/** Locals written anywhere in `list`. */ +function writes(list: readonly IrStmt[]): Set { + const out = new Set(); + everyStmtList(list as IrStmt[], { + expr: () => true, + stmt: (s) => { + const id = writtenLocal(s); + if (id !== null) out.add(id); + return true; + }, + }); + return out; +} + +/** Locals declared anywhere in `list`. */ +function declared(list: readonly IrStmt[]): Set { + const out = new Set(); + everyStmtList(list as IrStmt[], { + expr: () => true, + stmt: (s) => { + if (s.kind === "varDecl") out.add(s.localId); + return true; + }, + }); + return out; +} + +/** The jam of `loop`, or null. */ +function matchJam( + loop: IrStmt & { kind: "for" }, + locals: ReadonlyMap, +): Jam | null { + if (loop.labels?.length || loop.init?.kind !== "varDecl") return null; + const counter = loop.init.localId; + const local = locals.get(counter); + if (local?.type.kind !== "f64" || local.boxed || local.tdz) return null; + const start = intLit(loop.init.init); + const step = constantStep(loop.update, counter); + const cond = loop.cond; + if ( + start === null || + step === null || + cond?.kind !== "bin" || + cond.op !== "<" || + !isRef(cond.left, counter) + ) + return null; + const end = intLit(cond.right); + if (end === null) return null; + const values: number[] = []; + for (let v = start; v < end && values.length <= MAX_COPIES; v += step) values.push(v); + if (values.length < 2 || values.length > MAX_COPIES) return null; + // One inner loop, with straight-line code before and after it. + const at = loop.body.findIndex((s) => s.kind === "for"); + if (at < 0) return null; + const inner = loop.body[at] as IrStmt & { kind: "for" }; + const pre = loop.body.slice(0, at), + post = loop.body.slice(at + 1); + if (!pureStatements(pre, locals) || !pureStatements(post, locals)) return null; + if (countNodes(loop.body) > MAX_BODY_NODES) return null; + if (inner.labels?.length || inner.init?.kind !== "varDecl") return null; + const j = inner.init.localId; + const jLocal = locals.get(j); + if (jLocal?.type.kind !== "f64" || jLocal.boxed || jLocal.tdz) return null; + if (inner.init.init === null || !pureExpr(inner.init.init, locals)) return null; + if (inner.cond === null || !pureExpr(inner.cond, locals) || constantStep(inner.update, j) !== 1) + return null; + const acc = { id: null as string | null, bits: 0, breaks: [] as number[] }; + if (!innerStatements(inner.body, locals, acc) || acc.id === null) return null; + const full = acc.bits; + // Every exit tests the accumulator against the value every OR leaves + // unchanged once it is reached. + if (acc.breaks.length === 0 || acc.breaks.some((value) => value !== full)) return null; + const accId = acc.id; + // The accumulator starts at a literal in `pre`, and the inner loop changes + // it only by its ORs. + const accDecl = pre.find((s) => s.kind === "varDecl" && s.localId === accId); + if (accDecl?.kind !== "varDecl" || intLit(accDecl.init) === null) return null; + // The counter, the inner bound and every value the inner loop reads from + // outside the iteration are the same for every copy: only `pre` and the + // counter vary. + const own = declared([...pre, inner]); + own.delete(counter); + const innerWrites = writes([inner]); + innerWrites.delete(j); + for (const id of innerWrites) if (!own.has(id) && id !== accId) return null; + if (writes([inner]).has(counter) || writes(pre).has(counter) || writes(post).has(counter)) + return null; + // Copies run `pre` and the inner loop of later iterations before `post` + // of earlier ones: `pre` and the inner loop must not read what `post` + // writes, and `pre` writes only its own declarations. + const postWrites = writes(post); + for (const id of postWrites) if (reads([...pre, inner], id) || own.has(id)) return null; + for (const id of writes(pre)) if (!own.has(id)) return null; + // After the inner loop, iterations past a copy's exit are dead: `post` + // reads no value the inner loop writes besides the accumulator, and the + // inner bound reads nothing the copies write. + for (const id of innerWrites) if (id !== accId && reads(post, id)) return null; + for (const id of own) + if (id !== j && reads([{ kind: "exprStmt", expr: inner.cond, loc: inner.loc }], id)) + return null; + return { counter, values, pre, inner, post, acc: accId, full }; +} + +/** Runs the iterations of a short constant-count loop together when its body + * is an inner loop that exits once an accumulator reaches the value its ORs + * saturate at (mandelbrot's pairs of pixels per output byte): + * + * for (let i = 0; i < 8; i += 2) { + * + * for (let j = 0; j < M; j++) { + * ; if (c) { b |= 2; if (b === 3) break; } ... + * } + * + * } + * + * becomes every iteration's `pre` (with `i` a literal), one inner loop + * running every iteration's body in turn that exits when every accumulator + * is full, and every iteration's `post` in order. Once an accumulator is + * full its ORs leave it full, so the iterations a copy runs past its own + * exit leave its accumulator as its own loop left it, and every other value + * they compute is dead: the bodies only compute numbers, read typed-array + * 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. */ +export function jamSaturatingLoops(mod: IrModule): IrModule { + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (fn.async || fn.generator) return fn; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + let found = false; + everyStmtList(fn.body, { + expr: () => !found, + stmt: (s) => { + if (s.kind === "for" && matchJam(s, locals) !== null) found = true; + return !found; + }, + }); + if (!found) return fn; + const added: IrLocal[] = []; + const ids = new Set(fn.locals.map((local) => local.id)); + const fresh = (of: IrLocal, copy: number): string => { + let id = `%jam.${copy}.${of.id}`; + while (ids.has(id)) id += "_"; + ids.add(id); + const { source: _source, ...rest } = of; + added.push({ ...rest, id, name: `%jam.${of.name}` }); + return id; + }; + const body = transformStmtList(fn.body, { + expr: (e) => e, + stmt: (s) => { + if (s.kind !== "for") return s; + const jam = matchJam(s, locals); + return jam === null ? s : emitJam(jam, locals, fresh); + }, + }); + changed = true; + return { ...fn, locals: [...fn.locals, ...added], body }; + }); + return changed ? { ...mod, functions } : mod; +} + +function emitJam( + jam: Jam, + locals: ReadonlyMap, + fresh: (of: IrLocal, copy: number) => string, +): IrStmt { + 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; + own.delete(j); + const copies = values.map((value, copy) => { + const rename = new Map(); + for (const id of own) rename.set(id, fresh(locals.get(id)!, copy)); + const rewrite = (list: readonly IrStmt[]): IrStmt[] => + transformStmtList(list as IrStmt[], { + expr: (e) => { + if (e.kind !== "varRef") return e; + if (e.localId === counter) return { kind: "numLit", value, type: F64, loc: e.loc }; + const to = rename.get(e.localId); + return to === undefined ? e : { ...e, localId: to }; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") { + const to = rename.get(s.localId); + if (to !== undefined) return { ...s, localId: to }; + } + return s; + }, + }); + // The copy's exits become the shared test at the end of the iteration. + const withoutBreaks = (list: readonly IrStmt[]): IrStmt[] => + list.flatMap((s): IrStmt[] => { + if (s.kind !== "if") return [s]; + if (s.then.length === 1 && s.then[0]!.kind === "break") return []; + return [ + { ...s, then: withoutBreaks(s.then), else_: s.else_ ? withoutBreaks(s.else_) : null }, + ]; + }); + return { + pre: rewrite(pre), + body: rewrite(withoutBreaks(inner.body)), + post: rewrite(post), + acc: rename.get(jam.acc)!, + }; + }); + let allFull: IrExpr | null = null; + for (const copy of copies) { + const full: IrExpr = { + kind: "bin", + op: "===", + left: { kind: "varRef", localId: copy.acc, type: F64, loc }, + right: { kind: "numLit", value: jam.full, type: F64, loc }, + type: BOOL, + loc, + }; + allFull = + allFull === null + ? full + : { kind: "logical", op: "&&", left: allFull, right: full, type: BOOL, loc }; + } + const jammed: IrStmt = { + ...inner, + body: [ + ...copies.flatMap((copy) => copy.body), + { kind: "if", cond: allFull!, then: [{ kind: "break", loc }], else_: null, loc }, + ], + }; + return { + kind: "block", + body: [...copies.flatMap((copy) => copy.pre), jammed, ...copies.flatMap((copy) => copy.post)], + loc, + }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json new file mode 100644 index 000000000..34cd32574 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/saturating-loop-jams.ts" + ], + "diags": [] +} diff --git a/tests/corpus/saturating-loop-jams.ts b/tests/corpus/saturating-loop-jams.ts new file mode 100644 index 000000000..fdc066eb8 --- /dev/null +++ b/tests/corpus/saturating-loop-jams.ts @@ -0,0 +1,85 @@ +// Escape-time loops in the shape of mandelbrot's rows: a short constant-count +// loop over pixel pairs whose inner loop exits once both pixels of the pair +// have escaped (`b |= 2`, `b |= 1`, `if (b === 3) break`). Pixels escape at +// different iterations or never; each byte packs the pairs' bits in order. +const LIMIT = 4; + +function row(initial: Float64Array, ci: number, bytes: number, iterations: number): number[] { + const out: number[] = []; + for (let xByte = 0; xByte < bytes; xByte++) { + let res = 0; + for (let i = 0; i < 8; i += 2) { + const x = xByte << 3; + const cr1 = initial[x + i]!; + const cr2 = initial[x + i + 1]!; + let zr1 = cr1; + let zi1 = ci; + let zr2 = cr2; + let zi2 = ci; + let b = 0; + for (let j = 0; j < iterations; j++) { + const tr1 = zr1 * zr1; + const ti1 = zi1 * zi1; + zi1 = 2 * zr1 * zi1 + ci; + zr1 = tr1 - ti1 + cr1; + if (tr1 + ti1 > LIMIT) { + b |= 2; + if (b === 3) break; + } + const tr2 = zr2 * zr2; + const ti2 = zi2 * zi2; + zi2 = 2 * zr2 * zi2 + ci; + zr2 = tr2 - ti2 + cr2; + if (tr2 + ti2 > LIMIT) { + b |= 1; + if (b === 3) break; + } + } + res = (res << 2) | b; + } + out.push(~res & 255); + } + return out; +} + +/** Two pairs per value: the loop runs two iterations. */ +function quads(initial: Float64Array, ci: 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 = 0; j < 30; j++) { + zr1 = zr1 * zr1 + ci; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + ci; + 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; +let checksum = 0; +const lines: string[] = []; +for (let y = 0; y < size; y += 8) { + const ci = (2 * y) / size - 1; + const bytes = row(initial, ci, size >> 3, y % 16 === 0 ? 50 : 3); + lines.push(bytes.map((v) => v.toString(16).padStart(2, "0")).join("")); + for (const v of bytes) checksum = (checksum * 31 + v) % 1000003; +} +console.log(lines.join("\n")); +console.log(checksum); +const q = new Float64Array([0.1, 1.5, -0.3, 3]); +console.log(quads(q, 0.2), quads(q, -1.9), quads(new Float64Array(4), 0)); +// A row past the end of the coordinates reads NaN, which never escapes. +console.log(row(initial, 0.5, (size >> 3) + 1, 50).slice(-2)); diff --git a/tests/harness/corpus-costs/saturating-loop-jams.ts.json b/tests/harness/corpus-costs/saturating-loop-jams.ts.json new file mode 100644 index 000000000..e615532ed --- /dev/null +++ b/tests/harness/corpus-costs/saturating-loop-jams.ts.json @@ -0,0 +1 @@ +{ "plain": 5955, "san": 72997 } From db30118a0e61206cb291c5b795f6f1f93e826a3f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 19:18:01 -0500 Subject: [PATCH 17/47] Store tty.isatty and process.cwd as function values Fixes #2408 Co-authored-by: MathurAditya724 <57684218+MathurAditya724@users.noreply.github.com> Fixes #2402 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/llvm/reference-writes.ts | 1 + .../src/frontend/lowering/builtins/process.ts | 5 ++ .../lowering/expressions/property-access.ts | 25 ++++--- .../src/frontend/lowering/lower-stmts.ts | 10 +++ .../compiler/src/frontend/lowering/lowerer.ts | 69 ++++++++++++++++--- .../src/frontend/lowering/surfaces.ts | 35 +++++++--- .../compiler/src/ir/builtin-signatures.ts | 1 + .../tests/corpus/process-cwd-value.cjs.json | 6 ++ .../tests/corpus/process-cwd-value.mjs.json | 6 ++ .../corpus/tty-isatty-value-typed.ts.json | 6 ++ .../tests/corpus/tty-isatty-value.mjs.json | 6 ++ packages/runtime/src/scr_lib.c | 6 ++ packages/runtime/src/scr_lib.h | 1 + tests/corpus/process-cwd-value.cjs | 7 ++ tests/corpus/process-cwd-value.mjs | 22 ++++++ tests/corpus/tty-isatty-value-typed.ts | 10 +++ tests/corpus/tty-isatty-value.mjs | 21 ++++++ tests/diagnostics/stdlib.ts | 2 +- tests/harness/__snapshots__/stdlib.ts.txt | 8 +-- .../corpus-costs/process-cwd-value.cjs.json | 1 + .../corpus-costs/process-cwd-value.mjs.json | 1 + .../tty-isatty-value-typed.ts.json | 1 + .../corpus-costs/tty-isatty-value.mjs.json | 1 + 24 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json create mode 100644 tests/corpus/process-cwd-value.cjs create mode 100644 tests/corpus/process-cwd-value.mjs create mode 100644 tests/corpus/tty-isatty-value-typed.ts create mode 100644 tests/corpus/tty-isatty-value.mjs create mode 100644 tests/harness/corpus-costs/process-cwd-value.cjs.json create mode 100644 tests/harness/corpus-costs/process-cwd-value.mjs.json create mode 100644 tests/harness/corpus-costs/tty-isatty-value-typed.ts.json create mode 100644 tests/harness/corpus-costs/tty-isatty-value.mjs.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02479a922..0237bf2e3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -381,6 +381,7 @@ export const LIB_FN_SYMS: Record = { "process.stdio": "scr_process_stdio", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", + "process.isTTYValue": "scr_process_is_tty_value", "date.now": "scr_date_now", "date.newNow": "scr_date_now", "date.newMs": "scr_date_new_ms", diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 56379a443..9668d6494 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -118,6 +118,7 @@ const INERT_LIB_CALLS = new Set([ "process.platform", "process.execPath", "process.isTTY", + "process.isTTYValue", "worker.data", "worker.isMainThread", "worker.parentPort", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2eac7a17d..8654770ba 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -740,6 +740,11 @@ export function lowerProcessProperty( "process.env as a value of this type (read one variable: process.env.NAME or process.env[name])", ); } + // process.cwd as a value: the process module's member (surfaces.ts). + if (member === "cwd") { + const value = lowerer.lowerBuiltinCallableValue({ module: "process", member }, loc); + if (value) return value; + } if (member === "exit" || member === "cwd" || member === "getuid" || member === "kill") { lowerer.unsupported("SC1090", expr, `process methods as values (call '${member}' directly)`); } diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e566ed292..ea08cec65 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -24,7 +24,14 @@ import { localPrivateBrandClass, privateNameClass, } from "../private-brands.js"; -import { type Lowerer, PoisonError, jsFuncNameOf, neverTaintedJsType, own } from "../lowerer.js"; +import { + type Lowerer, + PoisonError, + builtinValueNameOf, + jsFuncNameOf, + neverTaintedJsType, + own, +} from "../lowerer.js"; import { ARRAY_METHODS, MAP_METHODS, @@ -1202,13 +1209,15 @@ export function lowerPropertyAccessExpr( (id) => lowerer.unions.get(id), ) ) { - const fnName = jsFuncNameOf(expr.expression); - // A builtin function value whose length is not its adapter's - // parameter count (`Math.max.length` is 2). - const fnLength = - probed.kind === "closure" - ? lowerer.builtinCallableValueLengths.get(probed.fnName) - : undefined; + // A builtin function value keeps Node's name, and its length when that + // is not its adapter's parameter count (`Math.max.length` is 2). Any + // other function is named by its definition, through const aliases. + const identity = lowerer.builtinFunctionIdentity(probed); + const fnName = + identity.fnName ?? + builtinValueNameOf(expr.expression, lowerer) ?? + jsFuncNameOf(expr.expression, lowerer); + const fnLength = identity.fnLength; const boxed: IrExpr = { kind: "dynFrom", value: probed, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..b948a4890 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4738,6 +4738,16 @@ function stdlibGlobalTokenDestructure( ) { // A data member reads its value once, exactly like the spelled // `process.` read the destructuring performs. + } else if ( + globalName === "process" && + propName === "cwd" && + (value = lowerer.lowerBuiltinCallableValue( + { module: "process", member: propName }, + locOf(el), + )) !== null + ) { + // process.cwd reads the directory when it is called, so the detached + // function is the same one every other spelling reads. } else { lowerer.unsupported( "SC1031", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..f916708e2 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2104,6 +2104,40 @@ function anyPiecedFuncType(lowerer: Lowerer, node: ts.Node, t: ts.Type): IrType * their own name, anonymous function/arrow expressions their * NamedEvaluation home (a variable initializer or property assignment). * Null when nothing names the value (the box stays anonymous). */ +/** The name Node gives the builtin module function `node` reads, through + * const aliases (`const get = process.cwd`), when the module table names its + * value differently from its member (BuiltinModuleFn.valueName); else null. */ +export function builtinValueNameOf(node: ts.Node, lowerer: Lowerer): string | null { + let n: ts.Node = node; + const seen = new Set(); + for (;;) { + while (ts.isParenthesizedExpression(n)) n = n.expression; + if (!ts.isIdentifier(n)) break; + const imported = lowerer.builtinImportOf(n); + if (imported) + return builtinModuleFnOf(lowerer, imported.module, imported.member)?.valueName ?? null; + const symbol = lowerer.resolveValueSymbol(n); + const declaration = symbol?.valueDeclaration; + if ( + !symbol || + seen.has(symbol) || + !declaration || + !ts.isVariableDeclaration(declaration) || + !declaration.initializer || + (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 + ) + return null; + seen.add(symbol); + n = declaration.initializer; + } + if (!ts.isPropertyAccessExpression(n)) return null; + const module = lowerer.isStdlibGlobal(n.expression, "process") + ? "process" + : lowerer.builtinNamespaceModuleOf(n.expression); + if (module === null) return null; + return builtinModuleFnOf(lowerer, module, n.name.text)?.valueName ?? null; +} + export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { let n: ts.Node = node; while (ts.isParenthesizedExpression(n)) n = n.expression; @@ -12303,20 +12337,13 @@ export class Lowerer { liveReference(expr.type) || (expr.type.kind === "union" && (this.unions.get(expr.type.unionId)?.arms.some(liveReference) ?? false)); - // A builtin function's adapter boxes under the builtin's name, the - // name Node's function carries (`[Function: randomUUID]`). - const builtinName = - expr.kind === "closure" ? this.builtinCallableValueNames.get(expr.fnName) : undefined; - const builtinLength = - expr.kind === "closure" ? this.builtinCallableValueLengths.get(expr.fnName) : undefined; return { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc, ...(liveRef ? { liveRef: true as const } : {}), - ...(builtinName !== undefined ? { fnName: builtinName } : {}), - ...(builtinLength !== undefined ? { fnLength: builtinLength } : {}), + ...this.builtinFunctionIdentity(expr), }; } return expr; @@ -12464,7 +12491,13 @@ export class Lowerer { ) { return { kind: "dynCheck", - value: { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc }, + value: { + kind: "dynFrom", + value: expr, + type: DYN, + loc: expr.loc, + ...this.builtinFunctionIdentity(expr), + }, type: expected, loc: expr.loc, }; @@ -16899,7 +16932,10 @@ export class Lowerer { if (!fnName) { fnName = keyedHelperName(this, `%builtin.value.${fn.valueFn ?? fn.fn}`, key); this.builtinCallableValueFns.set(key, fnName); - this.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1)); + this.builtinCallableValueNames.set( + fnName, + fn.valueName ?? display.slice(display.lastIndexOf(".") + 1), + ); if (fn.valueLength !== undefined) this.builtinCallableValueLengths.set(fnName, fn.valueLength); const params = funcType.params.map((type, index) => ({ @@ -16991,6 +17027,19 @@ export class Lowerer { return { kind: "closure", fnName, captures: [], type: funcType, loc }; } + /** The name and length a builtin function's adapter boxes under: the ones + * Node's function carries (`[Function: randomUUID]`). Empty for any other + * value. */ + builtinFunctionIdentity(expr: IrExpr): { fnName?: string; fnLength?: number } { + if (expr.kind !== "closure") return {}; + const name = this.builtinCallableValueNames.get(expr.fnName); + const length = this.builtinCallableValueLengths.get(expr.fnName); + return { + ...(name !== undefined ? { fnName: name } : {}), + ...(length !== undefined ? { fnLength: length } : {}), + }; + } + isBuiltinCallableValue(expr: IrExpr): boolean { return ( expr.kind === "closure" && diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..cef6f91bd 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -823,6 +823,9 @@ 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 function value's `name` when it is not the member's + * (`process.cwd` is Node's `wrappedCwd`). */ + valueName?: string; } export type BuiltinValueParam = @@ -1954,10 +1957,29 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { transcode: { fn: "buffer.transcode", params: [DYN, DYN, DYN], result: BYTES_U8 }, }, tty: { - isatty: { fn: "process.isTTY", params: [F64], result: BOOL }, + isatty: { + fn: "process.isTTY", + params: [F64], + result: BOOL, + // As a value (loggers store `tty.isatty`), the adapter takes any + // argument: a value that is not an integral fd answers false, and is + // never coerced. + valueFn: "process.isTTYValue", + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + valueLength: 1, + }, }, process: { loadEnvFile: { fn: "process.loadEnvFile", params: [DYN], result: VOID }, + // Every spelling of process.cwd as a value is this one function, which + // reads the directory when it is called. + cwd: { + fn: "process.cwd", + params: [], + result: STRING, + valueParams: [], + valueName: "wrappedCwd", + }, }, // node:string_decoder's surface is the StringDecoder CLASS — new/write/ // end are special-cased (lowerNew + lowerStringDecoderMethodCall); no @@ -2289,6 +2311,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< randomInt: ["crypto.randomIntMax", "crypto.randomIntCb", "crypto.randomIntPromise"], randomUUID: ["crypto.randomUUIDOpts"], }, + tty: { + isatty: ["process.isTTYValue"], + }, os: { // lowerOsUserInfoCall assembles the record from scalar libCalls. userInfo: ["os.userHomedir", "os.userShell"], @@ -2720,12 +2745,6 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] { fns: ["process.release"], note: "the compatibility target's release metadata as one stable read-only object", }, - { - id: "node-builtin.process.cwd", - kind: "node-builtin", - name: "process.cwd", - fns: ["process.cwd"], - }, { id: "node-builtin.process.chdir", kind: "node-builtin", @@ -2860,7 +2879,7 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] { id: "node-builtin.process.isTTY", kind: "node-builtin", name: "process.isTTY", - fns: ["process.isTTY"], + fns: ["process.isTTY", "process.isTTYValue"], note: "the isTTY read on process.stdin/stdout/stderr — one surface across the three streams", }, { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..918b6785f 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5337,6 +5337,7 @@ 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 }, + "process.isTTYValue": { argTypes: [DYN], result: BOOL }, // 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/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json new file mode 100644 index 000000000..2d6348884 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-cwd-value.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json new file mode 100644 index 000000000..165dc4ae4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-cwd-value.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json new file mode 100644 index 000000000..5dd1fbc01 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tty-isatty-value-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json new file mode 100644 index 000000000..5aa2813a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tty-isatty-value.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..aa1301d78 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5126,6 +5126,12 @@ bool scr_process_is_tty(double fd) { return isatty((int)fd) != 0; } +/* tty.isatty as a function value (borrowed argument): a value that is not a + * number answers false and is never coerced, as Node's integer test does. */ +bool scr_process_is_tty_value(const ScrDyn *fd) { + return fd != NULL && fd->kind == SCR_DYN_NUM && scr_process_is_tty(fd->v.num); +} + /* Terminal geometry for process.stdout/stderr.columns/rows: ioctl(TIOCGWINSZ) on * the stream's fd, exactly Node's tty.WriteStream source of truth. A * non-TTY stream, or a terminal that refuses the ioctl, answers -1 and diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..2dacf8cde 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -386,6 +386,7 @@ double scr_atomics_notify_dyn(const ScrDyn *, double, double); * non-TTY streams expose undefined (SEMANTICS.md). stdin_destroy is a * documented no-op. */ 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); diff --git a/tests/corpus/process-cwd-value.cjs b/tests/corpus/process-cwd-value.cjs new file mode 100644 index 000000000..6a6d616c6 --- /dev/null +++ b/tests/corpus/process-cwd-value.cjs @@ -0,0 +1,7 @@ +// process.cwd stored as a value from CommonJS: the same function as the +// process module's member, read when it is called. +const processModule = require("node:process"); +const get = process.cwd; +console.log(get === processModule.cwd, get.name, get() === process.cwd()); +const { cwd } = processModule; +console.log(cwd === get, typeof cwd()); diff --git a/tests/corpus/process-cwd-value.mjs b/tests/corpus/process-cwd-value.mjs new file mode 100644 index 000000000..016b914e6 --- /dev/null +++ b/tests/corpus/process-cwd-value.mjs @@ -0,0 +1,22 @@ +// process.cwd stored as a value: one identity across the global, named, +// namespace and default forms, Node's name and length, and the directory +// read when it is called. +import { cwd } from "node:process"; +import * as processModule from "node:process"; +import processDefault from "node:process"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const get = process.cwd; +console.log(typeof get, get.name, get.length); +console.log(get === cwd, get === processModule.cwd, get === processDefault.cwd); +console.log(get() === process.cwd(), cwd() === process.cwd()); + +const start = get(); +const dir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-cwd-value-"))); +process.chdir(dir); +console.log(get() === dir, cwd() === dir, [0].map(() => get())[0] === dir); +process.chdir(start); +console.log(get() === start); +rmSync(dir, { recursive: true }); diff --git a/tests/corpus/tty-isatty-value-typed.ts b/tests/corpus/tty-isatty-value-typed.ts new file mode 100644 index 000000000..7ec7bfb31 --- /dev/null +++ b/tests/corpus/tty-isatty-value-typed.ts @@ -0,0 +1,10 @@ +// A typed alias of tty.isatty works as a callback with its declared +// signature. +import * as tty from "node:tty"; + +const isTerminal: (fd: number) => boolean = tty.isatty; +function describe(fds: number[], test: (fd: number) => boolean): string[] { + return fds.map((fd) => `${fd}:${test(fd)}`); +} +console.log(describe([-1, 0.5, 2 ** 40], isTerminal).join(" ")); +console.log(isTerminal === tty.isatty, [-1].map(isTerminal)); diff --git a/tests/corpus/tty-isatty-value.mjs b/tests/corpus/tty-isatty-value.mjs new file mode 100644 index 000000000..56aab317e --- /dev/null +++ b/tests/corpus/tty-isatty-value.mjs @@ -0,0 +1,21 @@ +// tty.isatty stored as a value keeps the direct predicate's answers, its +// identity across import forms, and its name and length. Values that are +// not integral file descriptors answer false without being coerced. +import * as tty from "node:tty"; +import { isatty } from "node:tty"; +import ttyDefault from "node:tty"; + +const probe = tty.isatty; +console.log(typeof probe, probe.name, probe.length); +console.log(probe === isatty, probe === ttyDefault.isatty); +console.log(probe(-1), probe(1.5), probe(2 ** 31), probe(Number.NaN)); +console.log([-1, 0.5].map(probe)); +// Called through an untyped callback, values that are not numbers answer +// false without being coerced. +function forward(predicate, value) { + return predicate(value); +} +let reads = 0; +const object = { valueOf() { reads++; return 1; } }; +console.log(forward(probe, undefined), forward(probe, "1"), forward(probe, 1n), forward(probe, null), forward(probe, object), reads); +console.log(forward(isatty, -1), forward(tty.isatty, 2 ** 31)); diff --git a/tests/diagnostics/stdlib.ts b/tests/diagnostics/stdlib.ts index d88b5da2b..e8565c38d 100644 --- a/tests/diagnostics/stdlib.ts +++ b/tests/diagnostics/stdlib.ts @@ -6,7 +6,7 @@ import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; import { writeFile } from "node:fs/promises"; const read = readFileSync; console.log(read); -const cwd = process.cwd; +const exit = process.exit; const p = process; const env = process.env; const flo = Math.floor; // fixed-arity Math functions have native value forms diff --git a/tests/harness/__snapshots__/stdlib.ts.txt b/tests/harness/__snapshots__/stdlib.ts.txt index dbe79859a..83e222a99 100644 --- a/tests/harness/__snapshots__/stdlib.ts.txt +++ b/tests/harness/__snapshots__/stdlib.ts.txt @@ -3,13 +3,13 @@ stdlib.ts:8:40 - error SC1090: a builtin function alias as an escaping value (ca 7 | import { writeFile } from "node:fs/promises"; 8 | const read = readFileSync; console.log(read); | ^~~~ - 9 | const cwd = process.cwd; + 9 | const exit = process.exit; -stdlib.ts:9:13 - error SC1090: process methods as values (call 'cwd' directly) are not supported yet +stdlib.ts:9:14 - error SC1090: process methods as values (call 'exit' directly) are not supported yet 8 | const read = readFileSync; console.log(read); - 9 | const cwd = process.cwd; - | ^~~~~~~~~~~ + 9 | const exit = process.exit; + | ^~~~~~~~~~~~ 10 | const p = process; stdlib.ts:13:15 - error SC1090: string methods as values (call 'toUpperCase' directly) are not supported yet diff --git a/tests/harness/corpus-costs/process-cwd-value.cjs.json b/tests/harness/corpus-costs/process-cwd-value.cjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/process-cwd-value.cjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } diff --git a/tests/harness/corpus-costs/process-cwd-value.mjs.json b/tests/harness/corpus-costs/process-cwd-value.mjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/process-cwd-value.mjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } diff --git a/tests/harness/corpus-costs/tty-isatty-value-typed.ts.json b/tests/harness/corpus-costs/tty-isatty-value-typed.ts.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/tty-isatty-value-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } diff --git a/tests/harness/corpus-costs/tty-isatty-value.mjs.json b/tests/harness/corpus-costs/tty-isatty-value.mjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/tty-isatty-value.mjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } From 81cacab03df25852a21696087732bc994f7e2fb8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 11:46:59 -0700 Subject: [PATCH 18/47] Lower node:path's platform namespaces as path module values path.posix and path.win32 are module objects, not declarations: they are the same handle `process.getBuiltinModule("node:path")` answers, so `posix === path.posix` holds and a mutation through one spelling shows through the other. Reading either namespace as a value mapped to a module namespace type with no value lowering, and `import { posix } from "node:path"` was refused outright. The platform namespaces now lower to the checked-dynamic module value, as a `path.posix` property read, as a named import, and as a receiver (`win32.join(...)`); pathModuleValue answers the module handle instead of building a fresh object literal, which is what makes the identity and the cached members hold. Type mapping treats a path platform namespace and @types/node's PlatformPath interface as the dynamic type they are, so a union with a plain record arm collapses onto it instead of refusing. tests/corpus/7015-path-platform-namespace-values.ts pins the identity, cached members, joins and the import forms against Node. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/identifiers.ts | 12 +++++- .../frontend/lowering/lower-builtin-values.ts | 22 ++++------ .../src/frontend/lowering/lower-calls.ts | 12 +++++- packages/compiler/src/frontend/type-mapper.ts | 35 +++++++++++++++- ...015-path-platform-namespace-values.ts.json | 6 +++ .../7015-path-platform-namespace-values.ts | 41 +++++++++++++++++++ ...015-path-platform-namespace-values.ts.json | 1 + 7 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7015-path-platform-namespace-values.ts.json create mode 100644 tests/corpus/7015-path-platform-namespace-values.ts create mode 100644 tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..ebe7e2556 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -86,7 +86,7 @@ import { identifierReadSymbol, stdlibGlobalNameOf, } from "../surfaces.js"; -import { builtinConstantsObject } from "../lower-builtin-values.js"; +import { builtinConstantsObject, pathModuleValue } from "../lower-builtin-values.js"; import { lowerTlsRootCertificates } from "../lower-server.js"; import { builtinFunctionFenceValue } from "../builtin-function-fences.js"; import { unsupportedModuleFeatureOf } from "../../builtin-modules.js"; @@ -562,6 +562,16 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: } const c = builtinModuleConstOf(lowerer, bi.module, bi.member); if (c !== undefined) return builtinConstLit(c, loc); + // `import { posix, win32 } from "node:path"` as VALUES: the platform + // namespaces are members of the path module object, so the named + // import reads the same checked-dynamic module value the property + // form (path.posix) lowers to. + if ( + (bi.module === "path" || bi.module === "path/posix" || bi.module === "path/win32") && + (bi.member === "posix" || bi.member === "win32") + ) { + return pathModuleValue(lowerer, `path/${bi.member}`, loc); + } // module.builtinModules — the baked Node v24 list, a fresh // string[] per read. if (bi.module === "module" && bi.member === "builtinModules") { diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index d59b234d3..3b4180c95 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -426,21 +426,13 @@ const box = (value: IrExpr): IrExpr => value.type.kind === "dyn" ? value : { kind: "dynFrom", value, type: DYN, loc: value.loc }; export function pathModuleValue(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); - } - for (const member of Object.keys(BUILTIN_MODULE_CONSTS[module] ?? {})) { - const value = builtinModuleConstOf(lowerer, module, member); - if (value !== undefined) add(member, builtinConstLit(value, loc)); - } - add("posix", moduleValue(lowerer, "path/posix", loc)); - add("win32", moduleValue(lowerer, "path/win32", loc)); - return { kind: "dynObjLit", fields, type: DYN, loc }; + // node:path's namespaces are module objects: the same handle + // `process.getBuiltinModule("node:path")` answers. The handle caches + // each member (so `path.join === path.join` and a mutation through one + // spelling is visible through the other) and identifies the module, so + // `posix === path.posix` holds — the object model the platform + // namespaces need when they are VALUES (`const api = win32`). + return moduleValue(lowerer, module, loc); } export function utilTypesModuleValue(lowerer: Lowerer, loc: SrcLoc): IrExpr { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index f1398c66e..e2744b655 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3702,7 +3702,17 @@ function lowerDynReceiverMethodCall( const binding = ts.isIdentifier(access.expression) ? lowerer.builtinImportOf(access.expression) : null; - if (binding?.module !== "worker_threads" || binding.member !== "parentPort") return null; + const parentPort = binding?.module === "worker_threads" && binding.member === "parentPort"; + // node:path's platform namespaces resolve to ambient declarations the + // same way, but their VALUE is the checked-dynamic module object (the + // whole namespace), so `win32.join(...)` takes this receiver path + // exactly like `path.win32.join(...)`. + const pathNamespace = + (binding?.module === "path" || + binding?.module === "path/posix" || + binding?.module === "path/win32") && + (binding.member === "posix" || binding.member === "win32"); + if (!parentPort && !pathNamespace) return null; } // Only checker-untyped receivers: `any`/`unknown`, or the `any[]` an // Array.isArray guard narrows them to (the value is STILL the checked-dynamic tree diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 55a7631a4..7b531d134 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1649,6 +1649,27 @@ export function isLegacyUrlSymbol( * (errno, dlopen, priority) and SignalConstants, and the fallback's twins: * the runtime's checked-dynamic constants object, whose members vary by * platform. */ +/** True for node:path's platform-specific namespaces ("path/posix", + * "path/win32"). Their values are the path module object, so they map to + * the checked-dynamic module value (see pathModuleValue in + * lowering/lower-builtin-values.ts), not to a module declaration. */ +export function isPathPlatformNamespaceId(moduleId: string): boolean { + return moduleId === "builtin:path/posix" || moduleId === "builtin:path/win32"; +} + +/** @types/node's `PlatformPath` interface (module "path"): the declared type + * of `path.posix`/`path.win32`. Its values are the path module object. */ +function isPlatformPathType(type: ts.Type, ctx: Pick): boolean { + const sym = type.getSymbol(); + if (sym === undefined || ts.symbolName(sym) !== "PlatformPath") return false; + return sym.declarations.some( + (decl) => + ts.isInterfaceDeclaration(decl) && + ctx.isStdlibFile(ts.getSourceFileOfNode(decl)) && + isDeclaredInAmbientModule(decl, "path"), + ); +} + export function isOsConstantsType( type: ts.Type, ctx: Pick, @@ -2145,7 +2166,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } { const moduleId = ctx.moduleNamespaceId?.(type) ?? null; - if (moduleId !== null) return { kind: "moduleNs", moduleId }; + // node:path's platform namespaces (`path.posix`, `path.win32`) are + // ordinary objects at run time: the same module object the whole + // namespace lowers to (pathModuleValue). Typing them as a module + // namespace declaration made every value read a static type with no + // value lowering; the checked-dynamic module value is the honest + // type, and a union with a plain record arm collapses onto it. + if (moduleId !== null && !isPathPlatformNamespaceId(moduleId)) + return { kind: "moduleNs", moduleId }; + if (moduleId !== null) return DYN; } // A TypeScript object literal that spreads a checked-dynamic value // (`{ ...requestOptions, path, timeout }`): its keys are the source's @@ -2193,6 +2222,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "object", className: "%Error" }; } + // @types/node declares the platform-path namespace value as the + // `PlatformPath` interface of module "path"; its values ARE the path + // module object (the checked-dynamic module value). + if (isPlatformPathType(type, ctx)) return DYN; if (isOsConstantsType(type, ctx) || isLegacyUrlType(type, ctx)) return DYN; // The WebAssembly API's values have the checked-dynamic representation, so // declarations that merely mention them compile; each reached use of the diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7015-path-platform-namespace-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7015-path-platform-namespace-values.ts.json new file mode 100644 index 000000000..df48948fd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7015-path-platform-namespace-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7015-path-platform-namespace-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7015-path-platform-namespace-values.ts b/tests/corpus/7015-path-platform-namespace-values.ts new file mode 100644 index 000000000..bea5c325d --- /dev/null +++ b/tests/corpus/7015-path-platform-namespace-values.ts @@ -0,0 +1,41 @@ +// node:path's platform namespaces as VALUES. `posix` and `win32` are +// members of the path module object, so a chosen-at-run-time path API +// (`const api = cond ? win32 : { basename, dirname }`) is that object on +// one arm and a plain record on the other; calls through it dispatch on +// the value, as they do in Node. The namespaces keep the module object's +// identity and member interning (`posix === path.posix`, one closure per +// function), and a function read off the module namespace is the same +// value the named import binds. +import * as path from "node:path"; +import { basename, dirname, join, normalize, posix, win32 } from "node:path"; + +// The platform-namespace choice (the portable-API idiom): win32 here, so +// the calls answer win32's rules on every host, like Node's namespace. +const useWin32 = process.platform === "win32" || "C:\\tmp\\x".includes("\\"); +const api = useWin32 ? win32 : { basename, dirname }; + +const parent = api.dirname("/tmp/pkg/node_modules/@scope/name"); +console.log(parent, api.basename(parent), api.dirname(parent)); +console.log(api.basename("/a/b/c.txt"), api.basename("C:\\a\\b\\c.txt", ".txt")); + +// The same shape through a class/closure boundary and a reassignment. +function pick(flag: boolean): typeof win32 | { basename: typeof basename; dirname: typeof dirname } { + return flag ? win32 : { basename, dirname }; +} +console.log(pick(true).basename("C:\\x\\y.txt"), pick(false).basename("/x/y.txt")); + +// A member of the module namespace read as a value is the named import's +// value, and the whole namespace compares by module identity. +type PathModule = typeof import("node:path"); +const whole = path as PathModule; +const pathJoin = whole.join; +console.log(pathJoin("a", "b"), pathJoin === path.join, typeof pathJoin); +console.log(typeof posix, typeof win32, posix === path.posix, win32 === path.win32); +console.log(win32.join("a", "c")); + +// Direct calls and values mix freely; the namespace value answers every +// lowerable member. +const chosen = useWin32 ? win32 : posix; +console.log(chosen.join("a", "b", "..", "c"), chosen.normalize("a\\\\b//c")); +console.log(chosen.extname("C:\\a\\b.tar.gz"), chosen.isAbsolute("C:\\a"), chosen.sep); +console.log(join("x", "y"), normalize("a//b/../c")); diff --git a/tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json b/tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json new file mode 100644 index 000000000..016c16de0 --- /dev/null +++ b/tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 28700 } From f7a22cb0c768b84ab0e023ad9cda3e04fec8e3b8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 11:47:12 -0700 Subject: [PATCH 19/47] Lower `delete u.k` when every union arm declares k optional A union of records whose every arm declares the same field optional has a definite answer for `delete u.k`: the field becomes absent, exactly as the monomorphic optional-field delete writes it. The delete statement used to fall through to the receiver-agnostic path and refuse the union. lowerUnionFieldDelete dispatches over the arms (one tag test per arm, the last arm as the fall-through else) and writes each slot's absent value through a recordSet on the narrowed arm; the union value is retained once, so every arm deletes the object the program passed rather than a projection. Sloppy-mode writes keep their frozen-record semantics. Everything else keeps its fence: unit arms, class arms, required fields (a slot with no absence to represent), accessors, index signatures, optional chains and tuples all return null and reach the existing diagnostics. tests/corpus/7016-union-field-delete.ts pins the delete, the absence afterwards (`in`, optional reads, JSON.stringify), the retained identity the caller's alias sees, and deleting an already-absent field. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/union-fields.ts | 77 +++++++++++++++++++ .../src/frontend/lowering/lower-stmts.ts | 10 ++- .../corpus/7016-union-field-delete.ts.json | 6 ++ tests/corpus/7016-union-field-delete.ts | 51 ++++++++++++ .../7016-union-field-delete.ts.json | 1 + 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json create mode 100644 tests/corpus/7016-union-field-delete.ts create mode 100644 tests/harness/corpus-costs/7016-union-field-delete.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/union-fields.ts b/packages/compiler/src/frontend/lowering/expressions/union-fields.ts index 66cd9c42e..8f24f3a90 100644 --- a/packages/compiler/src/frontend/lowering/expressions/union-fields.ts +++ b/packages/compiler/src/frontend/lowering/expressions/union-fields.ts @@ -306,6 +306,83 @@ export function lowerUnionFieldWrite( return { kind: "seqExpr", stmts: body, result: dispatch, type: result.type, loc }; } +/** `delete u.f` on a union receiver: each record arm declares `f` as an + * OPTIONAL field (an undefined-armed slot), so each tag writes its own + * slot's ABSENT state — the monomorphic optional-field delete, dispatched + * over the arms the way union field writes are. The union value is + * retained once, so every arm deletes the object the program passed, not a + * projection. Null when an arm is not a record that declares `f` optional + * (unit arms, class arms, required fields, accessors and index signatures + * keep their own fences): a required slot has no absence to represent. */ +export function lowerUnionFieldDelete( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): IrStmt | null { + const mapped = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + if (access.questionDotToken || mapped?.kind !== "union") return null; + let receiver = lowerer.maybeNarrow(lowerer.lowerExpr(access.expression), access.expression); + if (receiver.type.kind === "union" && !typeEquals(receiver.type, mapped)) + receiver = narrowStoredClassValue(lowerer, receiver, mapped) ?? receiver; + if (receiver.type.kind !== "union") return null; + const unionId = receiver.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + if (!arms || arms.length < 2) return null; + const name = access.name.text; + const loc = locOf(access); + const fields: { arm: IrType & { kind: "record" }; type: IrType }[] = []; + for (const arm of arms) { + if (arm.kind !== "record" || lowerer.shapes.get(arm.shapeId)?.tuple) return null; + const field = unionFieldArm(lowerer, arm, name); + if (!field || lowerer.absentFieldValue(field.type, loc) === null) return null; + fields.push({ arm, type: field.type }); + } + const object = lowerer.declareHiddenLocal("%unionDeleteObject", receiver.type); + const objectRef = varRef(object.id, object.type, loc); + const branch = (tag: number): IrStmt[] => { + const field = fields[tag]!; + const absent = lowerer.absentFieldValue(field.type, loc); + if (absent === null) return []; + const write: IrStmt = { + kind: "recordSet", + obj: { kind: "unionNarrow", unionId, tag, value: objectRef, type: field.arm, loc }, + shapeId: field.arm.shapeId, + field: name, + value: absent, + loc, + }; + // A sloppy-mode delete on a frozen record leaves it unchanged. + markSloppyRecordWrites(lowerer, write, access); + return [write]; + }; + // One tag test per arm, the last arm as the fall-through else: a delete + // runs at most one arm's write. + const dispatch = (tag: number): IrStmt[] => + tag === fields.length - 1 + ? branch(tag) + : [ + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId, + tag, + value: objectRef, + negated: false, + type: BOOL, + loc, + }, + then: branch(tag), + else_: dispatch(tag + 1), + loc, + }, + ]; + return { + kind: "block", + body: [{ kind: "varDecl", localId: object.id, init: receiver, loc }, ...dispatch(0)], + loc, + }; +} + /** Read the element at a literal index from a union of tuples whose element * types differ (`readonly [true, number] | readonly [false, string]`, index * 1): each tag reads its own tuple's slot and widens it into the union of diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index afc14196e..0bd182fdf 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -258,7 +258,7 @@ import { import { keptIdentityAssertion } from "./assertion-identity.js"; import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { lowerUnionFieldWrite } from "./expressions/union-fields.js"; +import { lowerUnionFieldDelete, lowerUnionFieldWrite } from "./expressions/union-fields.js"; import { UNSUPPORTED, checkerPanicDiag, @@ -7711,6 +7711,14 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt loc, }; } + // `delete u.f` on a union of records that every arm declares `f` optional + // on: dispatch over the arms and write each slot's ABSENT state (the + // monomorphic optional-field delete). Required fields, class arms and + // other receivers keep their own fences below. + if (ts.isPropertyAccessExpression(target)) { + const unionDelete = lowerUnionFieldDelete(lowerer, target); + if (unionDelete) return unionDelete; + } let obj = lowerer.lowerExpr(target.expression); // `delete this.stack` in an Error subclass that declares a `stack` // accessor (quickjs-wasi's JSException): V8 gives each error an own diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json new file mode 100644 index 000000000..521f14ae1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7016-union-field-delete.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7016-union-field-delete.ts b/tests/corpus/7016-union-field-delete.ts new file mode 100644 index 000000000..58be84011 --- /dev/null +++ b/tests/corpus/7016-union-field-delete.ts @@ -0,0 +1,51 @@ +// `delete u.f` on a union receiver: every arm declares `f` as an OPTIONAL +// field, so each arm's slot can hold its ABSENT state and the delete +// removes the own property on whichever arm the value has — `in` and +// JSON.stringify agree with Node, and the mutation reaches the caller's +// object rather than a projection of it. A required field (a slot with no +// absence to represent) still refuses at compile time. +interface A { + type: "a"; + content: string; + details?: number; +} +interface B { + type: "b"; + content: string; + details?: string; +} +type Either = A | B; + +function strip(e: Either): Either { + delete e.details; + return e; +} + +const a: Either = { type: "a", content: "x", details: 1 }; +const b: Either = { type: "b", content: "y", details: "d" }; +console.log("before", "details" in a, "details" in b, JSON.stringify(a), JSON.stringify(b)); + +const outA = strip(a); +const outB = strip(b); +console.log("details" in outA, "details" in outB, JSON.stringify(outA), JSON.stringify(outB)); +console.log(outA.details, outB.details); + +// The delete mutates the value the caller passed (no copy): the alias that +// still holds the arm's own type sees the removal too. +console.log("alias", "details" in a, a.details); + +// Deleting an absent field is a no-op, and a later write makes the field +// present again. +const c: A = { type: "a", content: "z" }; +delete c.details; +console.log("absent", "details" in c, JSON.stringify(c), c.details); +c.details = 7; +console.log("present", "details" in c, JSON.stringify(c)); + +// Dispatch through a value produced at run time, both arms of the union. +const pick = (flag: boolean): Either => + flag ? strip({ type: "a", content: "p" }) : strip({ type: "b", content: "q", details: "r" }); +for (const value of [pick(true), pick(false)]) { + delete value.details; + console.log(value.type, "details" in value, JSON.stringify(value), value.content); +} diff --git a/tests/harness/corpus-costs/7016-union-field-delete.ts.json b/tests/harness/corpus-costs/7016-union-field-delete.ts.json new file mode 100644 index 000000000..450a8bf95 --- /dev/null +++ b/tests/harness/corpus-costs/7016-union-field-delete.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 4100 } From 4c4a53d5383678eb3f64dc52734da9603bf7238a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 11:55:01 -0700 Subject: [PATCH 20/47] Name the arms a union-typed spread merge cannot pick between MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spread merge like `{ ...base, ...override }` whose sources are union types has one field set per combination of source arms, so the merged value can carry fields from several arms at once. No single arm of the destination union can hold it — naming one would drop the other arms' fields, which Node keeps — and no runtime tag would be honest. The merge was refused with the generic "cannot be compiled yet" (SC2001), which says nothing about the shape the program is building. The new SC2003 names the destination union and lists the candidate arms, and its hint gives the two repairs: merge ONE arm per path (narrow every spread source to the same arm first), or declare one open record type whose fields are all optional instead of a union of all-optional arms. The diagnostic fires only for a union-typed destination; other non-record destinations keep badType. `NonNullable` over a union already maps to the union value itself, so tests/corpus/7017-nonnullable-union.ts pins the mapped-type path (assertions, generic instantiations, narrowing, serialization) against Node. tests/diagnostics/union-spread-merge.ts re-pins the refusal, and the union-clone-override-width snapshot moves from SC2001's generic text to the arm-naming SC2003. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/diagnostics/diagnostic.ts | 24 ++++++ .../lowering/expressions/object-literals.ts | 24 +++++- .../corpus/7017-nonnullable-union.ts.json | 6 ++ .../diagnostics/union-spread-merge.ts.json | 6 ++ tests/corpus/7017-nonnullable-union.ts | 74 +++++++++++++++++++ tests/diagnostics/union-spread-merge.ts | 26 +++++++ .../union-clone-override-width.ts.txt | 6 +- .../__snapshots__/union-spread-merge.ts.txt | 8 ++ .../7017-nonnullable-union.ts.json | 1 + 9 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json create mode 100644 tests/corpus/7017-nonnullable-union.ts create mode 100644 tests/diagnostics/union-spread-merge.ts create mode 100644 tests/harness/__snapshots__/union-spread-merge.ts.txt create mode 100644 tests/harness/corpus-costs/7017-nonnullable-union.ts.json diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index ced3de029..21c0b00f8 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -1114,6 +1114,30 @@ export function unionMismatchDiag( }; } +/** An object literal that SPREADS union-typed sources (`{ ...base, + * ...override }` where each source is `A | B | C`) builds one field set per + * combination of source arms. A union of records cannot hold that: each arm + * carries its own field table, so naming one arm would drop the fields the + * other arms contributed (Node keeps every field). The refusal names the + * candidate arms, because the honest repair is an explicit annotation per + * source (or one open record type), never a guessed arm. */ +export function unionLiteralMismatchDiag( + expectedText: string, + arms: readonly string[], + loc: SrcLoc, +): ScrDiagnostic { + const named = arms.map((a) => `'${a}'`).join(", "); + return { + code: "SC2003", + message: + `a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of '${expectedText}' can represent it` + + (arms.length > 0 ? ` (candidate arms: ${named})` : ""), + loc, + milestone: "later", + hint: "merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms", + }; +} + /** A use of a binding whose DECLARATION did not compile: the declaration * site already carries the real diagnostic (a fenced type, a blocked * signature, an unsupported binding form), and every later reference falls diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 9699a4fb9..492736883 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -20,7 +20,10 @@ import { import type { IrExpr, IrLocal, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js"; import { isCjsExportTableLiteral, isJsSourceFile, locOf } from "../../program.js"; import { NARROW_FIRST } from "../surfaces.js"; -import { recordShapeMismatchDiag } from "../../../diagnostics/diagnostic.js"; +import { + recordShapeMismatchDiag, + unionLiteralMismatchDiag, +} from "../../../diagnostics/diagnostic.js"; import { PoisonError, jsFuncNameOf, neverTaintedJsType } from "../lowerer.js"; import type { Lowerer } from "../lowerer.js"; import { lowerIndexMergeHelper } from "../containers/indexed-objects.js"; @@ -1301,7 +1304,24 @@ export function lowerObjectLiteral( if (fallback) return fallback; lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE); } - if (!mapped || mapped.kind !== "record") lowerer.badType(expr, tsType); + if (!mapped || mapped.kind !== "record") { + // A spread merge over UNION-typed sources (`{ ...base, ...override }` + // with `base`, `override` of arm-union types) has one field set per + // combination of source arms, so its own type is a union of records. + // No single arm of the contextual union can hold that value without + // dropping the fields the other arms contributed (Node keeps them + // all), and a runtime tag would be a guess about which arm the value + // "is". Refuse, naming the candidate arms. + if (mapped?.kind === "union") { + const members = tsType.isUnionType() ? ts.constituentTypes(tsType) : [tsType]; + const arms = [...new Set(members.map((m) => lowerer.checker.typeToString(m)))]; + lowerer.pushDiag( + unionLiteralMismatchDiag(lowerer.checker.typeToString(tsType), arms, locOf(expr)), + ); + throw new PoisonError(); + } + lowerer.badType(expr, tsType); + } let type: IrType = mapped; // A fresh, plain literal can construct its fields directly for a known // destination with the same field set. This matters for inferred diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json new file mode 100644 index 000000000..822d75cf5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7017-nonnullable-union.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json new file mode 100644 index 000000000..c388bced3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/union-spread-merge.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7017-nonnullable-union.ts b/tests/corpus/7017-nonnullable-union.ts new file mode 100644 index 000000000..728a19009 --- /dev/null +++ b/tests/corpus/7017-nonnullable-union.ts @@ -0,0 +1,74 @@ +// `NonNullable` over a union: the type mapper maps the checker's answer +// directly — the union with its null/undefined arms removed — so the result +// is an ordinary union VALUE (tagged, narrowed, serialized like any other), +// not a checked-dynamic slot. `Compat` carries no unit arm, so +// `NonNullable` is `Compat` itself; `Compat | undefined` loses the +// unit arm. The runtime values are Node's. +interface OpenAiCompat { + kind: "openai"; + maxTokensField?: string; + supportsStore?: boolean; +} + +interface AnthropicCompat { + kind: "anthropic"; + supportsLongCacheRetention?: boolean; +} + +type Compat = OpenAiCompat | AnthropicCompat; + +function takePrime(value: NonNullable): string { + return value; +} + +function takeCompat(value: NonNullable): Compat { + return value; +} + +function takeWhole(value: NonNullable): Compat { + return value; +} + +function takeNested(value: NonNullable | undefined>): Compat { + return value; +} + +// A generic `NonNullable` resolves per instantiation the same way. +function takeGeneric(value: T | undefined): NonNullable { + return value as NonNullable; +} + +function pick(flag: boolean): Compat { + return flag ? { kind: "openai", supportsStore: true } : { kind: "anthropic" }; +} + +const asserted = pick(true) as NonNullable; +const whole: NonNullable = pick(false); +const generic = takeGeneric({ kind: "openai", supportsStore: false }); + +console.log( + takePrime("p"), + JSON.stringify(takeCompat(pick(true))), + JSON.stringify(takeWhole({ kind: "openai", supportsStore: true })), + JSON.stringify(takeNested(pick(false))), + JSON.stringify(asserted), + JSON.stringify(whole), + JSON.stringify(generic), +); + +// The mapped type narrows like any union: each arm reads its own fields. +function describe(value: NonNullable): string { + if (value.kind === "openai") return `openai ${value.supportsStore} ${value.maxTokensField}`; + return `anthropic ${value.supportsLongCacheRetention}`; +} + +console.log( + describe({ kind: "openai", supportsStore: true }), + describe({ kind: "openai", maxTokensField: "max_completion_tokens" }), + describe({ kind: "anthropic" }), + describe({ kind: "anthropic", supportsLongCacheRetention: false }), +); + +// An array of the mapped type is an ordinary array of the union value. +const all: NonNullable[] = [takeCompat(pick(true)), pick(false)]; +for (const entry of all) console.log(describe(entry)); diff --git a/tests/diagnostics/union-spread-merge.ts b/tests/diagnostics/union-spread-merge.ts new file mode 100644 index 000000000..dae422070 --- /dev/null +++ b/tests/diagnostics/union-spread-merge.ts @@ -0,0 +1,26 @@ +// A spread merge over UNION-typed sources: `{ ...base, ...override }` has one +// field set per combination of source arms, so the merged value can carry +// fields from several arms at once. No single arm of the destination union +// can represent it (naming one would drop the other arms' fields, which Node +// keeps), and no runtime tag would be honest. The refusal names the candidate +// arms; the repair is one arm per merge or one open all-optional record type. +interface OpenAiCompat { + kind: "openai"; + supportsStore?: boolean; +} + +interface AnthropicCompat { + kind: "anthropic"; + supportsLongCacheRetention?: boolean; +} + +type Compat = OpenAiCompat | AnthropicCompat; + +function mergeCompat(base: Compat | undefined, override: Compat | undefined): Compat | undefined { + if (!override) return base; + const merged = { ...base, ...override } as NonNullable; + return merged; +} + +const result = mergeCompat({ kind: "openai" }, { kind: "anthropic" }); +console.log(result !== undefined); diff --git a/tests/harness/__snapshots__/union-clone-override-width.ts.txt b/tests/harness/__snapshots__/union-clone-override-width.ts.txt index 8915968ef..da3819baf 100644 --- a/tests/harness/__snapshots__/union-clone-override-width.ts.txt +++ b/tests/harness/__snapshots__/union-clone-override-width.ts.txt @@ -1,6 +1,8 @@ -union-clone-override-width.ts:8:18 - error SC2001: values of type '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') +union-clone-override-width.ts:8:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' can represent it (candidate arms: '{ kind: "left"; left: number; payload: WiderPayload; }', '{ kind: "right"; right: number; payload: WiderPayload; }') 7 | // copy its record. The inferred spread result keeps the wider payload. 8 | const result = { ...source, payload }; | ^~~~~~~~~~~~~~~~~~~~~~ - 9 | return result.payload.extra; \ No newline at end of file + 9 | return result.payload.extra; + + hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/__snapshots__/union-spread-merge.ts.txt b/tests/harness/__snapshots__/union-spread-merge.ts.txt new file mode 100644 index 000000000..e3b39e2b6 --- /dev/null +++ b/tests/harness/__snapshots__/union-spread-merge.ts.txt @@ -0,0 +1,8 @@ +union-spread-merge.ts:21:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat') + + 20 | if (!override) return base; + 21 | const merged = { ...base, ...override } as NonNullable; + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 22 | return merged; + + hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/corpus-costs/7017-nonnullable-union.ts.json b/tests/harness/corpus-costs/7017-nonnullable-union.ts.json new file mode 100644 index 000000000..a5a099a2e --- /dev/null +++ b/tests/harness/corpus-costs/7017-nonnullable-union.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2700 } From cf7814d19c2d27be35414c2b75309bd168825b05 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 11:55:02 -0700 Subject: [PATCH 21/47] Lower fs statSync/lstatSync/fstatSync with { bigint: true } MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node answers a BigIntStats when the stat call is passed `bigint: true`: the same snapshot, read through a bigint member table. Every number member of Stats reads as a bigint — the integers, the `*Ms` millisecond members (the truncated nanosecond quotient, as the binding computes them) and the BigIntStats-only `*Ns` nanosecond members (sec * 1e9 + nsec, exactly) — while only the four time members stay Dates. scriptc refused the option outright. The representation is static, chosen by the option literal: the lowerer validates `bigint` against the call's result type (so `{ bigint: true, throwIfNoEntry: false }` still narrows `BigIntStats | undefined`), refuses a runtime-valued option and an option that disagrees with the destination, and keeps every other option key fenced. fstatSync joins statSync/lstatSync on the same path, with the descriptor evaluated before the options. The IR gains a `bigintStats` handle kind beside `stats`: the same RC family, the same non-traced leaf and non-dynamic handle treatment, so no shape, tracing or dyn-check behavior moves for any program that never asks for one. Its member table is a `bigstats.*` lib-call family whose Date members reuse the existing stat reads. The runtime support is its own unit, scr_stats_bigint.c: the snapshot layout moved into scr_stats_private.h so the bigint getters can read the same fields without scr_lib.c carrying their code. The unit is gated on the existing `bigint` feature in both the runtime-pack matrix and the compiler's from-source unit table (the sanitized lane links from that one), and `-ffunction-sections` keeps a bigint program that never reads a member from linking any of the getters. No typed program's IR or LLVM changes. tests/corpus/7018-bigint-stats.ts pins the member table, the Date/bigint split, ms/ns agreement, arithmetic on a member, lstat over a symlink, fstatSync over a descriptor, the throwIfNoEntry union, and the plain Stats answers of the un-optioned and `bigint: false` forms against Node. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/fs.d.ts | 65 +++++- .../src/backend/executable-features.ts | 1 + packages/compiler/src/backend/llvm/emitter.ts | 1 + .../compiler/src/backend/llvm/lib-dispatch.ts | 1 + .../src/backend/llvm/lib-filesystem.ts | 11 +- .../compiler/src/backend/llvm/lib-shared.ts | 29 +++ .../src/backend/llvm/nullable-unions.ts | 2 + .../compiler/src/backend/llvm/record-views.ts | 1 + packages/compiler/src/backend/llvm/shapes.ts | 1 + .../src/backend/native-link-info-core.ts | 4 + .../compiler/src/backend/native/contracts.ts | 3 + .../compiler/src/backend/native/executable.ts | 5 + .../src/executable/executable-cache.ts | 2 + .../frontend/lowering/builtins/filesystem.ts | 193 +++++++++++++++--- .../lowering/builtins/module-calls.ts | 14 +- .../frontend/lowering/builtins/properties.ts | 30 ++- .../lowering/expressions/property-access.ts | 25 ++- .../src/frontend/lowering/lower-exprs.ts | 1 + packages/compiler/src/frontend/type-mapper.ts | 17 ++ packages/compiler/src/index.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 34 +++ packages/compiler/src/ir/fields.generated.ts | 5 + packages/compiler/src/ir/ir.ts | 11 + .../compiler/src/ir/runtime-features.test.ts | 15 ++ packages/compiler/src/ir/runtime-features.ts | 11 +- packages/compiler/src/ir/traverse.ts | 1 + packages/compiler/src/ir/validate.ts | 13 +- .../tests/corpus/7018-bigint-stats.ts.json | 6 + .../runtime-pack-matrix.mjs | 3 + .../test/runtime-pack-matrix.test.ts | 9 + packages/runtime/src/scr_lib.c | 31 +-- packages/runtime/src/scr_stats_bigint.c | 84 ++++++++ packages/runtime/src/scr_stats_private.h | 43 ++++ tests/corpus/7018-bigint-stats.ts | 79 +++++++ tests/diagnostics/bigint-fences.ts | 3 + .../__snapshots__/bigint-fences.ts.txt | 20 +- .../corpus-costs/7018-bigint-stats.ts.json | 1 + 37 files changed, 686 insertions(+), 90 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json create mode 100644 packages/runtime/src/scr_stats_bigint.c create mode 100644 packages/runtime/src/scr_stats_private.h create mode 100644 tests/corpus/7018-bigint-stats.ts create mode 100644 tests/harness/corpus-costs/7018-bigint-stats.ts.json diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 534fad940..044926914 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -387,9 +387,43 @@ declare module "node:fs" { readonly ctime: Date; readonly birthtime: Date; } + /* Node's BigIntStats: the same snapshot under the bigint member table. + * Every number member of Stats — including the *Ms millisecond members — + * answers a bigint, and the *Ns nanosecond members sit beside them; the + * four time members stay Dates. */ + export interface BigIntStats { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + isBlockDevice(): boolean; + isCharacterDevice(): boolean; + isFIFO(): boolean; + isSocket(): boolean; + readonly dev: bigint; + readonly mode: bigint; + readonly nlink: bigint; + readonly uid: bigint; + readonly gid: bigint; + readonly rdev: bigint; + readonly blksize: bigint; + readonly ino: bigint; + readonly size: bigint; + readonly blocks: bigint; + readonly atimeMs: bigint; + readonly mtimeMs: bigint; + readonly ctimeMs: bigint; + readonly birthtimeMs: bigint; + readonly atimeNs: bigint; + readonly mtimeNs: bigint; + readonly ctimeNs: bigint; + readonly birthtimeNs: bigint; + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; + } /* throwIfNoEntry: false answers undefined for a missing entry (ENOENT or - * ENOTDIR) instead of throwing. BigIntStats (bigint: true) has no - * lowering. */ + * ENOTDIR) instead of throwing. bigint: true answers BigIntStats. */ export interface StatSyncOptions { bigint?: boolean; throwIfNoEntry?: boolean; @@ -399,24 +433,38 @@ declare module "node:fs" { path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false }, ): Stats | undefined; + export function statSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry: false }, + ): BigIntStats | undefined; export function statSync( path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true }, ): Stats; - /* bigint: true type-checks so the lowering can name it as unsupported. */ - export function statSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats; + export function statSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry?: true }, + ): BigIntStats; + /* A runtime-valued `bigint` is refused at lowering, so the catch-all keeps + * the Stats answer the other reachable forms give. */ export function statSync(path: string | URL, options: StatSyncOptions): Stats | undefined; export function lstatSync(path: string | URL, options?: undefined): Stats; export function lstatSync( path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false }, ): Stats | undefined; + export function lstatSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry: false }, + ): BigIntStats | undefined; export function lstatSync( path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true }, ): Stats; - /* bigint: true type-checks so the lowering can name it as unsupported. */ - export function lstatSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats; + export function lstatSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry?: true }, + ): BigIntStats; export function lstatSync(path: string | URL, options: StatSyncOptions): Stats | undefined; /* The fd pair behind spawn's fd-stdio form: openSync(path, flags) → * the raw fd. Numeric flags use an inline OR of the O_* constants. */ @@ -498,8 +546,9 @@ declare module "node:fs" { position?: number | null, encoding?: "utf8" | "utf-8", ): number; - /* statSync over an open fd — the same Stats snapshot. */ - export function fstatSync(fd: number): Stats; + /* statSync over an open fd — the same snapshot. */ + export function fstatSync(fd: number, options?: StatSyncOptions & { bigint?: false }): Stats; + export function fstatSync(fd: number, options: StatSyncOptions & { bigint: true }): BigIntStats; /* realpath(3) — resolves symlinks, `.`/`..`, throwing Node's fs error * shapes for missing paths. */ export function realpathSync(path: string | URL): string; diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..15cab8778 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -51,6 +51,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ emitter: features.emitter, symbol: features.symbol, bigint: features.bigint, + ...(features.bigstats ? { bigstats: true } : {}), searchParams: features.searchParams, qs: features.qs, utilPromisify: features.utilPromisify, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ae533b31f..76bc52902 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4606,6 +4606,7 @@ export class LlEmitter { case "url": case "searchParams": case "stats": + case "bigintStats": case "fileHandle": case "dir": case "spawnRes": diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 57a09d85d..8378fabea 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -1170,6 +1170,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "dir": case "watcher": case "stats": + case "bigstats": case "zlib": case "atomics": return host.emitFilesystemLibCall(e); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 9174f4775..ea275738e 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, STRING, type IrExpr } 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"; @@ -551,9 +551,12 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): 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 statsTag = def ? def.arms.findIndex((a) => a.kind === "stats") : -1; + const statsTag = def + ? def.arms.findIndex((a) => a.kind === "stats" || a.kind === "bigintStats") + : -1; + const statsArm = def?.arms[statsTag]; const undefTag = undefinedArmTag(e.type, host.unionsById); - if (statsTag < 0 || undefTag < 0) + if (statsTag < 0 || undefTag < 0 || !statsArm) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`); const path = host.emitExpr(e.args[0]!); const throws = host.emitExpr(e.args[1]!); @@ -564,7 +567,7 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): `${raw} = call ptr @scr_fs_stat_probe(ptr ${path.name}, i1 zeroext ${noFollow}, i1 zeroext ${throws.name})`, ); host.emitPendingCheck(); - return host.wrapNullable(raw, raw, STATS_T, statsTag, e.type, undefTag); + return host.wrapNullable(raw, raw, statsArm, statsTag, e.type, undefTag); } if (e.fn === "fs.readdirTypesSync" || e.fn === "fsp.readdirTypes") { // Dirent rows assembled inline from one scandir snapshot — the C diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c0d13b175..31ed383c1 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -620,6 +620,35 @@ export const LIB_FN_SYMS: Record = { "stats.mtime": "scr_stats_mtime", "stats.ctime": "scr_stats_ctime", "stats.birthtime": "scr_stats_birthtime", + "bigstats.isFile": "scr_stats_is_file", + "bigstats.isDirectory": "scr_stats_is_dir", + "bigstats.isSymbolicLink": "scr_stats_is_symlink", + "bigstats.isBlockDevice": "scr_stats_is_block_device", + "bigstats.isCharacterDevice": "scr_stats_is_character_device", + "bigstats.isFIFO": "scr_stats_is_fifo", + "bigstats.isSocket": "scr_stats_is_socket", + "bigstats.size": "scr_stats_big_size", + "bigstats.dev": "scr_stats_big_dev", + "bigstats.ino": "scr_stats_big_ino", + "bigstats.blocks": "scr_stats_big_blocks", + "bigstats.nlink": "scr_stats_big_nlink", + "bigstats.mode": "scr_stats_big_mode", + "bigstats.uid": "scr_stats_big_uid", + "bigstats.gid": "scr_stats_big_gid", + "bigstats.rdev": "scr_stats_big_rdev", + "bigstats.blksize": "scr_stats_big_blksize", + "bigstats.atimeMs": "scr_stats_big_atime_ms", + "bigstats.mtimeMs": "scr_stats_big_mtime_ms", + "bigstats.ctimeMs": "scr_stats_big_ctime_ms", + "bigstats.birthtimeMs": "scr_stats_big_birthtime_ms", + "bigstats.atimeNs": "scr_stats_big_atime_ns", + "bigstats.mtimeNs": "scr_stats_big_mtime_ns", + "bigstats.ctimeNs": "scr_stats_big_ctime_ns", + "bigstats.birthtimeNs": "scr_stats_big_birthtime_ns", + "bigstats.atime": "scr_stats_atime", + "bigstats.mtime": "scr_stats_mtime", + "bigstats.ctime": "scr_stats_ctime", + "bigstats.birthtime": "scr_stats_birthtime", "text.decode": "scr_text_decode", "bytes.bufferSource": "scr_bytes_buffer_source", "bytes.copyBrand": "scr_bytes_copy_brand", diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index 3f7ca8ca1..46789eb5c 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -142,6 +142,7 @@ export type NullableRefArm = | { kind: "record"; shapeId: string } | { kind: "string" } | { kind: "stats" } + | { kind: "bigintStats" } | (IrType & { kind: "array" | "bytes" | "map" | "set" | "func" }); /** The arm is a JS object: always truthy. */ @@ -358,6 +359,7 @@ function nullableShape( return { ...base, arm: { kind: "record", shapeId: arm.shapeId } }; if (arm.kind === "string") return { ...base, arm: { kind: "string" } }; if (arm.kind === "stats") return { ...base, arm: { kind: "stats" } }; + if (arm.kind === "bigintStats") return { ...base, arm: { kind: "bigintStats" } }; if ( arm.kind === "array" || arm.kind === "bytes" || diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 5574d1dde..0f4d18370 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -56,6 +56,7 @@ const LEAF_KINDS: ReadonlySet = new Set([ "url", "searchParams", "stats", + "bigintStats", "undefinedT", "nullT", "void", diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 993a1ffa5..8c683f1c9 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -1286,6 +1286,7 @@ function arrUsesRef(host: ShapeHost, elem: IrType): boolean { elem.kind === "child" || // spawned child handles: scr_child_* adapters, no trace elem.kind === "netServer" || elem.kind === "stats" || // fs.Stats snapshots: scr_stats_* adapters, no trace + elem.kind === "bigintStats" || // the same snapshot handle, BigIntStats reads elem.kind === "dyn" || // native collection seeds/drains: traced REF elem.kind === "regex" || // RegExp values: scr_regex_* adapters, no trace (no refs inside) elem.kind === "date" || // Date objects: scr_date_* adapters, no trace (no refs inside) diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..ded050bce 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -97,6 +97,10 @@ export interface NativeLinkFeatures { emitter: boolean; symbol: boolean; bigint: boolean; + /** BigIntStats member reads (`statSync(p, { bigint: true })` members): + * scr_stats_bigint.c. Absent (false) otherwise, so BigInt programs that + * never read a stat member keep their link line and size. */ + bigstats?: boolean; searchParams: boolean; qs: boolean; utilPromisify: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..5026fd5f2 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -188,6 +188,9 @@ export interface CcOptions { * Symbol-free binaries keep their exact link line. */ symbol?: boolean; bigint?: boolean; + /** BigIntStats member reads: compiles scr_stats_bigint.c. Every other + * bigint program keeps its exact link line and size. */ + bigstats?: boolean; /** Records URLSearchParams use in the build identity. Its runtime unit is * part of the base sources because checked URL handles expose searchParams. */ searchParams?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..449710115 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"), + // BigIntStats member reads (`statSync(p, { bigint: true })` integers and + // `*Ns`/`*Ms` members). Gated on the reads themselves (the bigstats + // feature), not on bigint: every bigint value or wasm conversion turns + // bigint on, and the linked getters would move their executable. + unit((u) => u.opts.bigstats === true, "scr_stats_bigint.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, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index c7c21f64f..83032c679 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -94,6 +94,8 @@ export interface EarlyExecutableNativeFeatures { emitter: boolean; symbol: boolean; bigint: boolean; + /** BigIntStats member reads: scr_stats_bigint.c. Absent (false) otherwise. */ + bigstats?: boolean; searchParams: boolean; qs: boolean; utilPromisify: boolean; diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index d8272c795..21c505fab 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -12,6 +12,7 @@ import { } from "../surfaces.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { + BIGINT_STATS_T, BOOL, BYTES_U8, DIR_T, @@ -1077,6 +1078,18 @@ export const STATS_TYPE_TESTS: Record = { isSocket: "stats.isSocket", }; +/** The same type tests on a BigIntStats receiver (statSync with + * `{ bigint: true }`): one more snapshot read, no bigint arithmetic. */ +const BIGINT_STATS_TYPE_TESTS: Record = { + isFile: "bigstats.isFile", + isDirectory: "bigstats.isDirectory", + isSymbolicLink: "bigstats.isSymbolicLink", + isBlockDevice: "bigstats.isBlockDevice", + isCharacterDevice: "bigstats.isCharacterDevice", + isFIFO: "bigstats.isFIFO", + isSocket: "bigstats.isSocket", +}; + /** Method calls on Stats-typed receivers: the type tests are pure reads on * the stat snapshot (a followed statSync snapshot never answers true to * isSymbolicLink — take lstatSync's, Node's own split). Anything else @@ -1094,10 +1107,14 @@ export function lowerStatsMethodCall( ) { return null; } - if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "stats") return null; + const receiverKind = lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind; + if (receiverKind !== "stats" && receiverKind !== "bigintStats") return null; if (!lowerer.isStdlibMember(access)) return null; const name = access.name.text; - const fn = Object.hasOwn(STATS_TYPE_TESTS, name) ? STATS_TYPE_TESTS[name] : undefined; + // BigIntStats' type tests are the same snapshot mode read; only the kind + // of the handle differs. + const table = receiverKind === "bigintStats" ? BIGINT_STATS_TYPE_TESTS : STATS_TYPE_TESTS; + const fn = Object.hasOwn(table, name) ? table[name] : undefined; if (fn && call.arguments.nodes.length === 0) { const receiver = lowerer.lowerExpr(access.expression); return { kind: "libCall", fn, args: [receiver], type: BOOL, loc: locOf(call) }; @@ -1482,14 +1499,67 @@ function lowerFsReaddirNames( /** fs.statSync / fs.lstatSync's documented option keys. */ const FS_STAT_DOCUMENTED_OPTIONS: ReadonlySet = new Set(["bigint", "throwIfNoEntry"]); +/** fs.fstatSync's: an open descriptor never misses, so there is no + * throwIfNoEntry. */ +const FS_FSTAT_DOCUMENTED_OPTIONS: ReadonlySet = new Set(["bigint"]); + +/** The stat representation a mapped result type carries: BigIntStats and + * `BigIntStats | undefined` read the bigint member table, Stats and + * `Stats | undefined` the number one; anything else is neither. */ +function statRepresentation( + callType: IrType | undefined | null, + arms: readonly IrType[] | undefined, +): "bigint" | "number" | null { + const kindOf = (t: IrType): "bigint" | "number" | null => + t.kind === "bigintStats" ? "bigint" : t.kind === "stats" ? "number" : null; + if (callType === undefined || callType === null) return null; + const own = kindOf(callType); + if (own !== null) return own; + if (arms !== undefined) { + for (const arm of arms) { + const repr = kindOf(arm); + if (repr !== null) return repr; + } + } + return null; +} + +/** The `bigint` option member. The literal picks the representation — + * BigIntStats for true, Stats for false — and the call's own type must + * already agree with it, because a `Stats | BigIntStats`-valued flag has no + * static member table. */ +function checkStatBigintOption( + lowerer: Lowerer, + value: ts.Expression, + spelling: string, + representation: "bigint" | "number" | null, +): void { + const t = lowerer.typeOf(value); + const literal = t.flags & ts.TypeFlags.BooleanLiteral ? lowerer.checker.typeToString(t) : null; + if (literal === null) { + lowerer.noLowering( + `${spelling} with a runtime-valued bigint option`, + value, + "pass the literal true or false: the result's representation is chosen statically", + ); + } + if (representation === null || (literal === "true") !== (representation === "bigint")) { + lowerer.noLowering( + `${spelling} with bigint: ${literal} where the result is not ${literal === "true" ? "BigIntStats" : "Stats"}`, + value, + "pass bigint: true to receive BigIntStats, or omit it to receive Stats", + ); + } +} /** fs.statSync / fs.lstatSync(path, options). The options are an object - * literal, where throwIfNoEntry takes any boolean and bigint must be a - * literal false (BigIntStats has no lowering), or a stored record whose - * only member is a boolean throwIfNoEntry. When the call's type is - * `Stats | undefined`, the call lowers to the probe, which answers - * undefined for a missing entry without building the error. An explicitly - * undefined options argument is the one-argument form. */ + * literal, where throwIfNoEntry takes any boolean and bigint is the literal + * true (BigIntStats) or false (Stats), or a stored record whose only member + * is a boolean throwIfNoEntry. When the call's type is + * `Stats | undefined` (or `BigIntStats | undefined`), the call lowers to the + * probe, which answers undefined for a missing entry without building the + * error. An explicitly undefined options argument is the one-argument + * form. */ export function lowerFsStatOptionsCall( lowerer: Lowerer, call: ts.CallExpression, @@ -1500,9 +1570,18 @@ export function lowerFsStatOptionsCall( const plainFn: IrLibFn = noFollow ? "fs.lstatSync" : "fs.statSync"; const optsNode = call.arguments.nodes[1]!; const path = lowerFsPath(lowerer, call.arguments.nodes[0]!); + // `{ bigint: true }` selects Node's BigIntStats: the SAME snapshot handle, + // read through the bigint member table. The checker's answer for the call + // already names the variant, so the mapped call type is the source of + // truth and the option member below has to agree with it. + const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const def = callType?.kind === "union" ? lowerer.unions.get(callType.unionId) : undefined; + const representation = statRepresentation(callType, def?.arms); + const statsType: IrType = representation === "bigint" ? BIGINT_STATS_T : STATS_T; // The path binds first, then the options' effects, then the stat runs. const plainAfter = (effect: IrExpr | null): IrExpr => { - if (effect === null) return { kind: "libCall", fn: plainFn, args: [path], type: STATS_T, loc }; + if (effect === null) + return { kind: "libCall", fn: plainFn, args: [path], type: statsType, loc }; const pathLocal = lowerer.declareHiddenLocal("%statPath", STRING); return { kind: "seqExpr", @@ -1514,10 +1593,10 @@ export function lowerFsStatOptionsCall( kind: "libCall", fn: plainFn, args: [varRef(pathLocal.id, STRING, loc)], - type: STATS_T, + type: statsType, loc, }, - type: STATS_T, + type: statsType, loc, }; }; @@ -1591,14 +1670,7 @@ export function lowerFsStatOptionsCall( if (m.name === "throwIfNoEntry") { throwIfNoEntry = lowerer.lowerExprExpecting(m.value, BOOL); } else if (m.name === "bigint") { - const t = lowerer.typeOf(m.value); - if (!(t.flags & ts.TypeFlags.BooleanLiteral) || lowerer.checker.typeToString(t) !== "false") { - lowerer.noLowering( - `${spelling} with bigint: true`, - m.value, - "BigIntStats has no lowering; omit bigint for Stats", - ); - } + checkStatBigintOption(lowerer, m.value, spelling, representation); } else { fenceOrDropOptionKey( lowerer, @@ -1606,12 +1678,11 @@ export function lowerFsStatOptionsCall( m.name, spelling, FS_STAT_DOCUMENTED_OPTIONS, - "throwIfNoEntry and bigint: false are the supported options", + "throwIfNoEntry and bigint are the supported options", ); } } - const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); - if (callType?.kind === "stats") { + if (callType?.kind === "stats" || callType?.kind === "bigintStats") { // A runtime flag that is false answers undefined, which a Stats result // cannot hold. if (throwIfNoEntry !== null && !(throwIfNoEntry.kind === "boolLit" && throwIfNoEntry.value)) { @@ -1623,11 +1694,10 @@ export function lowerFsStatOptionsCall( } return plainAfter(null); } - const def = callType?.kind === "union" ? lowerer.unions.get(callType.unionId) : undefined; if ( !def || def.arms.length !== 2 || - !def.arms.some((a) => a.kind === "stats") || + !def.arms.some((a) => a.kind === "stats" || a.kind === "bigintStats") || !def.arms.some((a) => a.kind === "undefinedT") ) { lowerer.noLowering( @@ -1645,6 +1715,81 @@ export function lowerFsStatOptionsCall( }; } +/** fs.fstatSync(fd, options): the descriptor form of the same snapshot. + * Node documents only `bigint` here — an open descriptor cannot miss, so the + * result is Stats or BigIntStats, never undefined. */ +export function lowerFsStatFdOptionsCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const spelling = "fstatSync"; + const fd = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, F64); + const optsNode = call.arguments.nodes[1]!; + const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const representation = statRepresentation(callType, undefined); + const statsType: IrType = representation === "bigint" ? BIGINT_STATS_T : STATS_T; + const plain = (): IrExpr => ({ + kind: "libCall", + fn: "fs.fstatSync", + args: [fd], + type: statsType, + loc, + }); + const undefinedOptions = lowerStaticallyUndefinedArgument(lowerer, optsNode); + if (undefinedOptions !== null) { + if (isSafeToDiscard(undefinedOptions)) return plain(); + // The descriptor binds first, then the options' effects, then the stat. + const fdLocal = lowerer.declareHiddenLocal("%fstatFd", F64); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: fdLocal.id, init: fd, loc: fd.loc }, + { kind: "exprStmt", expr: undefinedOptions, loc: undefinedOptions.loc }, + ], + result: { + kind: "libCall", + fn: "fs.fstatSync", + args: [varRef(fdLocal.id, F64, loc)], + type: statsType, + loc, + }, + type: statsType, + loc, + }; + } + if (!ts.isObjectLiteralExpression(optsNode)) { + lowerer.noLowering( + `${spelling} with this options value`, + optsNode, + `pass an object literal: ${spelling}(fd, { bigint: true })`, + ); + } + for (const p of optsNode.properties.nodes) { + const m = optionMember(p); + if (!m) { + lowerer.noLowering( + `${spelling} with this options shape`, + p, + "spreads and computed keys have no lowering — write each member inline", + ); + } + if (m.name === "bigint") { + checkStatBigintOption(lowerer, m.value, spelling, representation); + } else { + fenceOrDropOptionKey( + lowerer, + p, + m.name, + spelling, + FS_FSTAT_DOCUMENTED_OPTIONS, + "bigint is the supported option", + ); + } + } + return plain(); +} + /** `d.isFile()` / `d.isDirectory()` / `d.isSymbolicLink()` on a Dirent- * typed receiver (the interned record — provenance via the checker's * Dirent symbol, the StringDecoder pattern): a read of the hidden diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index bf5223d2f..cd9db34ab 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -38,7 +38,12 @@ import { type ParamShape } from "../call-signatures.js"; import { BYTES_U8, DYN, F64, type IrExpr, 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"; +import { + lowerFsWatchCall, + lowerFsReaddirTypesCall, + lowerFsStatFdOptionsCall, + lowerFsStatOptionsCall, +} from "./filesystem.js"; import { lowerZlibModuleCall } from "./compression.js"; import { lowerForkCall } from "./child-process.js"; import { @@ -162,8 +167,8 @@ export function lowerBuiltinModuleCall( if ((syncReaddirTypes || promiseReaddirTypes) && expr.arguments.nodes.length === 2) { return lowerFsReaddirTypesCall(lowerer, expr, loc, promiseReaddirTypes); } - // fs.statSync/fs.lstatSync(path, options): throwIfNoEntry and bigint: - // false, routed BEFORE the 1-arg table completion. + // fs.statSync/fs.lstatSync(path, options) and fs.fstatSync(fd, options): + // throwIfNoEntry and bigint, routed BEFORE the table completion. if ( bi.module === "fs" && (bi.member === "statSync" || bi.member === "lstatSync") && @@ -171,6 +176,9 @@ export function lowerBuiltinModuleCall( ) { return lowerFsStatOptionsCall(lowerer, expr, loc, bi.member === "lstatSync"); } + if (bi.module === "fs" && bi.member === "fstatSync" && expr.arguments.nodes.length === 2) { + return lowerFsStatFdOptionsCall(lowerer, expr, loc); + } if (bi.module === "os" && bi.member === "userInfo") { return lowerOsUserInfoCall(lowerer, expr, loc); } diff --git a/packages/compiler/src/frontend/lowering/builtins/properties.ts b/packages/compiler/src/frontend/lowering/builtins/properties.ts index 70512fed9..063fa05be 100644 --- a/packages/compiler/src/frontend/lowering/builtins/properties.ts +++ b/packages/compiler/src/frontend/lowering/builtins/properties.ts @@ -5,6 +5,7 @@ import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { isChildSurfaceMember } from "../surfaces.js"; import { + BIGINT_T, BOOL, CHILDSTREAM_T, CHILDWRITER_T, @@ -49,6 +50,22 @@ const STATS_FIELDS: Record = { birthtime: "stats.birthtime", }; const DATE_FIELDS = new Set(["atime", "mtime", "ctime", "birthtime"]); +/** BigIntStats answers a `bigint` for EVERY number member of Stats — the + * integers, the `*Ms` millisecond members (the truncated nanosecond + * quotient, as Node's binding computes them) and the BigIntStats-only `*Ns` + * nanosecond members. Only the four time members stay `Date`s. */ +const NANOSECOND_FIELDS: Record = { + atimeNs: "bigstats.atimeNs", + mtimeNs: "bigstats.mtimeNs", + ctimeNs: "bigstats.ctimeNs", + birthtimeNs: "bigstats.birthtimeNs", +}; +const BIGINT_STATS_FIELDS: Record = { + ...Object.fromEntries( + Object.entries(STATS_FIELDS).map(([name]) => [name, `bigstats.${name}` as IrLibFn]), + ), + ...NANOSECOND_FIELDS, +}; export function lowerBuiltinExtraProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -136,6 +153,7 @@ export function lowerBuiltinExtraProperty( } if ( kind !== "stats" && + kind !== "bigintStats" && kind !== "fileHandle" && kind !== "dir" && kind !== "spawnRes" && @@ -260,11 +278,15 @@ export function lowerBuiltinExtraProperty( lowerer.checker.getSymbolAtLocation(expr.name), ); } - if (kind === "stats" && Object.hasOwn(STATS_FIELDS, name)) { + const statsFields = + kind === "stats" ? STATS_FIELDS : kind === "bigintStats" ? BIGINT_STATS_FIELDS : undefined; + if (statsFields !== undefined && Object.hasOwn(statsFields, name)) { const receiver = lowerer.lowerExpr(expr.expression); - const fn = STATS_FIELDS[name]!; - // The Date fields are the *Ms values rounded, as Node's dateFromMs builds them. - const type = DATE_FIELDS.has(name) ? DATE_T : F64; + const fn = statsFields[name]!; + // The Date fields are the millisecond value rounded, as Node's + // dateFromMs builds them; every other Stats number is a bigint on + // BigIntStats. + const type = DATE_FIELDS.has(name) ? DATE_T : kind === "bigintStats" ? BIGINT_T : F64; return { kind: "libCall", fn, args: [receiver], type, loc }; } if (kind === "spawnRes" && name === "signal") { diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index f3e226078..c3eb31800 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -44,6 +44,7 @@ import { isParseArgsDynTypeName, } from "../../type-mapper.js"; import { + BIGINT_T, BOOL, BYTES_U8, DYN, @@ -2097,6 +2098,7 @@ export function lowerIntrinsicProperty( kind !== "url" && kind !== "searchParams" && kind !== "stats" && + kind !== "bigintStats" && kind !== "spawnRes" && kind !== "child" && kind !== "bytes" && @@ -2252,18 +2254,31 @@ export function lowerIntrinsicProperty( lowerer.checker.getSymbolAtLocation(expr.name), ); } - if (kind === "stats") { + if (kind === "stats" || kind === "bigintStats") { + const bigintStats = kind === "bigintStats"; if (name === "size") { const receiver = lowerer.lowerExpr(expr.expression); - return { kind: "libCall", fn: "stats.size", args: [receiver], type: F64, loc: locOf(expr) }; + return { + kind: "libCall", + fn: bigintStats ? "bigstats.size" : "stats.size", + args: [receiver], + type: bigintStats ? BIGINT_T : F64, + loc: locOf(expr), + }; } if (Object.hasOwn(STATS_TYPE_TESTS, name)) { - lowerer.unsupported("SC1090", expr, `Stats methods as values (call '${name}' directly)`); + lowerer.unsupported( + "SC1090", + expr, + `${bigintStats ? "BigIntStats" : "Stats"} methods as values (call '${name}' directly)`, + ); } lowerer.noLowering( - `Stats.${name}`, + `${bigintStats ? "BigIntStats" : "Stats"}.${name}`, expr, - "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", + bigintStats + ? "BigIntStats' isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeNs, mtimeNs, ctimeNs, birthtimeNs, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported members" + : "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", lowerer.checker.getSymbolAtLocation(expr.name), ); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 690f8a5ef..306fbcb2c 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2953,6 +2953,7 @@ export function typeofAnswer(arm: IrType): string | null { case "url": case "searchParams": case "stats": + case "bigintStats": case "fileHandle": case "dir": case "spawnRes": diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 7b531d134..3fa9ba4ee 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1087,6 +1087,8 @@ export function formatIrType( return append("bigint"); case "stats": return append("Stats"); + case "bigintStats": + return append("BigIntStats"); case "fileHandle": return append("FileHandle"); case "dir": @@ -3716,6 +3718,21 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "stats" }; } + // fs.BigIntStats: the SAME snapshot handle under Node's bigint member + // table (`statSync(p, { bigint: true })`). Declared by @types/node as + // `class BigIntStats` in "fs"; the fallback declarations keep the class + // shape too. Provenance-checked like Stats. + if ( + psym !== undefined && + ts.symbolName(psym) === "BigIntStats" && + psym.declarations.some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(ts.getSourceFileOfNode(d)), + ) + ) { + return { kind: "bigintStats" }; + } // Capacity snapshots and options keep their live object representation // across numeric/BigInt overloads, runtime options, and callbacks. if ( diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c7e24b53d..b2b90938d 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -730,6 +730,7 @@ async function compileExecutableNative( emitter: features.emitter, symbol: features.symbol, bigint: features.bigint, + ...(features.bigstats ? { bigstats: true } : {}), searchParams: features.searchParams, qs: features.qs, utilPromisify: features.utilPromisify, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 5cf97702f..87b572a74 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2,6 +2,7 @@ import type { IrType } from "./ir.js"; import { BIGINT_T } from "./ir.js"; import { arrayOf, + BIGINT_STATS_T, BOOL, BYTES_U8, bytesOf, @@ -1365,6 +1366,39 @@ function buildLibFnSignatures() { "stats.mtime": { argTypes: [STATS_T], result: DATE_T }, "stats.ctime": { argTypes: [STATS_T], result: DATE_T }, "stats.birthtime": { argTypes: [STATS_T], result: DATE_T }, + /** BigIntStats (statSync with `{ bigint: true }`): the same snapshot + * handle under Node's bigint member table — every number member of Stats + * (the integers, `*Ms` and the BigIntStats-only `*Ns`) answers `bigint`; + * only the four time members stay `Date`s. */ + "bigstats.isFile": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isDirectory": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isSymbolicLink": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isBlockDevice": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isCharacterDevice": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isFIFO": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isSocket": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.size": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.dev": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.ino": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.blocks": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.nlink": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.mode": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.uid": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.gid": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.rdev": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.blksize": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.atimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.mtimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.ctimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.birthtimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.atimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.mtimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.ctimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.birthtimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.atime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, + "bigstats.mtime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, + "bigstats.ctime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, + "bigstats.birthtime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, // The wider sync fs slice (unlink/chmod/chown/copyfile and the // mode-carrying write/mkdir forms). /** The wider sync fs slice (scr_lib.c), all throwing catchably with diff --git a/packages/compiler/src/ir/fields.generated.ts b/packages/compiler/src/ir/fields.generated.ts index 86c6a15e5..ae45b987b 100644 --- a/packages/compiler/src/ir/fields.generated.ts +++ b/packages/compiler/src/ir/fields.generated.ts @@ -1863,6 +1863,10 @@ export function walkTypeFields(node: IrType, walker: IrFieldWalker, skipType: bo walker.string(node.kind); return; } + case "bigintStats": { + walker.string(node.kind); + return; + } case "bool": { walker.string(node.kind); return; @@ -2268,6 +2272,7 @@ export type IrFieldsCurrent = [ Unread, "kind" | "elem" | "optionalElements">>, Unread["optionalElements"]>>, Unread, "kind">>, + Unread, "kind">>, Unread, "kind">>, Unread, "kind" | "elem">>, Unread, "kind">>, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 7200da6dd..4c2cfc5d0 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -171,6 +171,12 @@ export type IrType = * never part of a cycle, no trace. Same container rules as url: union * arms fine, arrays/maps/JSON fenced. */ | { kind: "stats" } + /** statSync/lstatSync/fstatSync with `{ bigint: true }`: the SAME snapshot + * handle as `stats` (scr_stats_* retain/release), with Node's BigIntStats + * member table — the integer and `*Ns` members read as `bigint`, the `*Ms` + * members as `number`, the four time members as `Date`. Distinct kind so + * the member reads and the type identity differ from Stats. */ + | { kind: "bigintStats" } /** A node:fs/promises FileHandle: heap, refcounted, MUTABLE — aliases * share one descriptor slot, so close() is idempotent and every alias * observes fd === -1 after closure. The final release closes a still- @@ -401,6 +407,7 @@ export type IrType = const POINTER_HANDLE_KINDS = [ "stats", + "bigintStats", "fileHandle", "dir", "spawnRes", @@ -485,6 +492,7 @@ export const RUNTIME_RC_STEMS: Record = { searchParams: "scr_sp", symbol: "scr_sym", stats: "scr_stats", + bigintStats: "scr_stats", fileHandle: "scr_file_handle", dir: "scr_dir", spawnRes: "scr_spawn_res", @@ -552,6 +560,7 @@ export const REF_TRUTHY_KINDS: ReadonlySet = new Set([ "url", "searchParams", "stats", + "bigintStats", "fileHandle", "dir", "spawnRes", @@ -596,6 +605,7 @@ export const URL_T: IrType = { kind: "url" }; export const SEARCH_PARAMS_T: IrType = { kind: "searchParams" }; export const SYMBOL_T: IrType = { kind: "symbol" }; export const STATS_T: IrType = { kind: "stats" }; +export const BIGINT_STATS_T: IrType = { kind: "bigintStats" }; export const FILEHANDLE_T: IrType = { kind: "fileHandle" }; export const DIR_T: IrType = { kind: "dir" }; export const SPAWNRES_T: IrType = { kind: "spawnRes" }; @@ -669,6 +679,7 @@ export function isSupportedArrayElem(t: IrType): boolean { // fs.Stats snapshots (`await Promise.all(paths.map(stat))`): immutable // refcounted handles with their own retain/release adapters. case "stats": + case "bigintStats": case "symbol": case "classval": case "moduleNs": diff --git a/packages/compiler/src/ir/runtime-features.test.ts b/packages/compiler/src/ir/runtime-features.test.ts index 7f53a45e6..feb00e079 100644 --- a/packages/compiler/src/ir/runtime-features.test.ts +++ b/packages/compiler/src/ir/runtime-features.test.ts @@ -42,6 +42,21 @@ describe("runtime feature snapshots", () => { expect(moduleUsesHttp2(mod)).toBe(false); }); + test("gates the BigIntStats unit on its member reads, not on bigint", () => { + const reads = module(); + reads.functions[0]!.body.push(call("bigstats.size")); + expect(moduleRuntimeFeatures(reads)).toMatchObject({ bigint: true, bigstats: true }); + + const storage = module(); + storage.functions[0]!.locals.push({ + id: "value", + name: "value", + mutable: false, + type: { kind: "bigint" }, + }); + expect(moduleRuntimeFeatures(storage)).toMatchObject({ bigint: true, bigstats: false }); + }); + test("keeps legacy HTTP/2 calls and isolated CA inspection out of the HTTP/2 unit", () => { const mod = module(); mod.functions[0]!.body.push(call("http2.streamNoop")); diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..28677f3ec 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -850,12 +850,18 @@ const RUNTIME_FEATURE_RULES = { * queueMicrotask scheduling goes through the runtime's *_als entry * points, which carry the active stores into each callback. */ asyncContext: rule({ fns: [...ASYNC_CONTEXT_LIB_FNS] }), - /** scr_wasm.c converts i64 values through BigInt. */ + /** scr_wasm.c converts i64 values through BigInt, and BigIntStats reads + * produce bigint values. */ bigint: rule({ fns: ["process.hrtimeValue"], - prefixes: ["bigint.", "wasm."], + prefixes: ["bigint.", "wasm.", "bigstats."], types: ["bigint"], }), + /** BigIntStats member reads (`statSync(p, { bigint: true })` members) link + * scr_stats_bigint.c. The bigint feature is broader than this unit: every + * bigint value and wasm conversion turns it on, so gating the unit on it + * would link the getters into programs that never name a stat member. */ + bigstats: rule({ prefixes: ["bigstats."] }), childProcess: rule({ fns: [...CHILD_PROCESS_LIB_FNS], prefixes: CHILD_PROCESS_LIB_PREFIXES, @@ -1264,6 +1270,7 @@ function noRuntimeFeatures(): RuntimeFeatures { assert: false, asyncContext: false, bigint: false, + bigstats: false, childProcess: false, cipher: false, copying: false, diff --git a/packages/compiler/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index e83bf5c42..053502c6f 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -64,6 +64,7 @@ export function everyTypeChild(node: IrType, visit: (node: IrType) => boolean): case "searchParams": case "symbol": case "stats": + case "bigintStats": case "fileHandle": case "dir": case "spawnRes": diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..f591c6dd4 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -19,6 +19,7 @@ import type { import { arrayOf, iterableArmsElem, + BIGINT_STATS_T, BYTES_ELEMENT_NAME, BOOL, BYTES_U8, @@ -51,6 +52,7 @@ import { RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, shapeHasAccessorSlots, + STATS_T, STRING, typeEquals, typeKey, @@ -4239,7 +4241,14 @@ function validateFunction( } } if (!typeEquals(e.type, sig.result)) { - err(`libCall ${e.fn} must be ${sig.result.kind}, got ${e.type.kind}`, e.loc); + // The stat calls answer the SAME snapshot under either member table: + // their `bigint` option picks BigIntStats' reads over Stats'. + const statsVariant = + (e.fn === "fs.statSync" || e.fn === "fs.lstatSync" || e.fn === "fs.fstatSync") && + typeEquals(sig.result, STATS_T) && + typeEquals(e.type, BIGINT_STATS_T); + if (!statsVariant) + err(`libCall ${e.fn} must be ${sig.result.kind}, got ${e.type.kind}`, e.loc); } } @@ -4922,7 +4931,7 @@ function validateFunction( const ok = def && def.arms.length === 2 && - def.arms.some((a) => a.kind === "stats") && + def.arms.some((a) => a.kind === "stats" || a.kind === "bigintStats") && def.arms.some((a) => a.kind === "undefinedT"); if (!ok) { err(`libCall ${e.fn} must return the 'Stats | undefined' union`, e.loc); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json new file mode 100644 index 000000000..a74fe1704 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7018-bigint-stats.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..e725ce77c 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -62,6 +62,9 @@ export const BASE_RUNTIME_SOURCES = [ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_copying.c", "copying"], ["scr_file_handle.c", "fileHandle"], + // BigIntStats member reads (`statSync(p, { bigint: true })` members). The + // bigstats gate is narrower than bigint, which every bigint value turns on. + ["scr_stats_bigint.c", "bigstats"], ["scr_regex.c", "regex"], ["scr_glob.c", "regex"], ["scr_idna.c", "regex"], 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..4d6495738 100644 --- a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts +++ b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts @@ -40,3 +40,12 @@ test("web streams and Node islands select an async build that defines both", () selected("scr_async.c", { webStreams: false, nodeIsland: false, workers: false }).defines, ).toEqual([]); }); + +test("the BigIntStats unit follows its own member-read gate, not bigint", () => { + const unit = matrix.runtime_units.find( + (u: { source: string }) => u.source === "scr_stats_bigint.c", + ); + // Every bigint value turns the bigint feature on; only the member reads + // name bigstats, so a bigint program keeps this unit out of its binary. + expect(unit.predicate).toBe("bigstats"); +}); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..349b876ff 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5238,33 +5238,10 @@ void scr_process_stdin_destroy(void) { * rejects (see the fsp section). scr_stats_dyn builds Node's Stats and * BigIntStats objects from the same snapshot. */ -/* The integers in the order Node's binding reports them. */ -enum { - SCR_STATS_DEV, - SCR_STATS_MODE, - SCR_STATS_NLINK, - SCR_STATS_UID, - SCR_STATS_GID, - SCR_STATS_RDEV, - SCR_STATS_BLKSIZE, - SCR_STATS_INO, - SCR_STATS_SIZE, - SCR_STATS_BLOCKS, - SCR_STATS_FIELDS -}; - -/* The timestamps, each kept as seconds + nanoseconds. */ -enum { SCR_STATS_ATIME, SCR_STATS_MTIME, SCR_STATS_CTIME, SCR_STATS_BIRTHTIME }; - -struct ScrStats { - size_t rc; - bool is_file; - bool is_dir; - bool is_symlink; /* lstat only — a followed stat never sees one */ - uint64_t fields[SCR_STATS_FIELDS]; /* blocks counts 512-byte units */ - int64_t sec[4]; - int64_t nsec[4]; -}; +/* The layout (the field enum, the timestamp enum and the struct) lives in + * scr_stats_private.h so scr_stats_bigint.c can read the same snapshot + * without this unit carrying its code. */ +#include "scr_stats_private.h" ScrStats *scr_stats_retain(ScrStats *s) { if (s->rc != SIZE_MAX) s->rc++; diff --git a/packages/runtime/src/scr_stats_bigint.c b/packages/runtime/src/scr_stats_bigint.c new file mode 100644 index 000000000..d598ee148 --- /dev/null +++ b/packages/runtime/src/scr_stats_bigint.c @@ -0,0 +1,84 @@ +/* scriptc runtime — BigIntStats member reads (scr_lib.c's stat snapshot, + * read through Node's bigint member table). Linked only into programs whose + * IR reads a BigIntStats member (the compiler's `bigint` runtime feature), so + * a program that never asks for `statSync(p, { bigint: true })` members links + * none of this. + * + * Node's BigIntStats answers a bigint for EVERY number member of Stats: the + * integers, the `*Ms` millisecond members (the truncated nanosecond + * quotient, as the binding computes them) and the BigIntStats-only `*Ns` + * nanosecond members (sec * 1e9 + nsec, exactly as scr_lib.c's dynamic + * conversion does). The four time members are the base unit's Date reads. + * Every function here answers +1 like the rest of the snapshot accessors. */ +#include "scr_bigint.h" +#include "scr_lib.h" +#include "scr_stats_private.h" + +/* The binding hands its BigInt64Array signed values. */ +static ScrBigInt *scr_stats_big_field(const ScrStats *s, size_t index) { + return scr_bigint_from_i64((int64_t)s->fields[index]); +} + +ScrBigInt *scr_stats_big_dev(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_DEV); } +ScrBigInt *scr_stats_big_mode(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_MODE); } +ScrBigInt *scr_stats_big_nlink(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_NLINK); } +ScrBigInt *scr_stats_big_uid(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_UID); } +ScrBigInt *scr_stats_big_gid(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_GID); } +ScrBigInt *scr_stats_big_rdev(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_RDEV); } +ScrBigInt *scr_stats_big_blksize(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_BLKSIZE); } +ScrBigInt *scr_stats_big_ino(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_INO); } +ScrBigInt *scr_stats_big_size(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_SIZE); } +ScrBigInt *scr_stats_big_blocks(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_BLOCKS); } + +/* nsFromTimeSpecBigInt: sec * 1_000_000_000 + nsec in exact bigint + * arithmetic (a 64-bit product would overflow far outside the range a + * filesystem timestamp can hold, but exactness is free here). */ +static ScrBigInt *scr_stats_big_ns_of(const ScrStats *s, int which) { + ScrBigInt *sec = scr_bigint_from_i64(s->sec[which]); + ScrBigInt *nsec = scr_bigint_from_i64(s->nsec[which]); + ScrBigInt *billion = scr_bigint_from_i64(1000000000); + ScrBigInt *scaled = scr_bigint_mul(sec, billion); + ScrBigInt *total = scr_bigint_add(scaled, nsec); + scr_bigint_release(sec); + scr_bigint_release(nsec); + scr_bigint_release(billion); + scr_bigint_release(scaled); + return total; +} + +/* The millisecond member: the nanosecond count divided by 1e6 (bigint + * division truncates toward zero). */ +static ScrBigInt *scr_stats_big_ms_of(const ScrStats *s, int which) { + ScrBigInt *total = scr_stats_big_ns_of(s, which); + ScrBigInt *million = scr_bigint_from_i64(1000000); + ScrBigInt *ms = scr_bigint_div(total, million); + scr_bigint_release(total); + scr_bigint_release(million); + return ms; +} + +ScrBigInt *scr_stats_big_atime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_ATIME); +} +ScrBigInt *scr_stats_big_mtime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_MTIME); +} +ScrBigInt *scr_stats_big_ctime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_CTIME); +} +ScrBigInt *scr_stats_big_birthtime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_BIRTHTIME); +} + +ScrBigInt *scr_stats_big_atime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_ATIME); +} +ScrBigInt *scr_stats_big_mtime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_MTIME); +} +ScrBigInt *scr_stats_big_ctime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_CTIME); +} +ScrBigInt *scr_stats_big_birthtime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_BIRTHTIME); +} diff --git a/packages/runtime/src/scr_stats_private.h b/packages/runtime/src/scr_stats_private.h new file mode 100644 index 000000000..9897fe15b --- /dev/null +++ b/packages/runtime/src/scr_stats_private.h @@ -0,0 +1,43 @@ +/* scriptc runtime — the stat snapshot's layout (scr_lib.c). PRIVATE: the + * enums index fields[] and the two time arrays exactly as scr_lib.c fills + * them. Only scr_lib.c itself and the BigIntStats member reads + * (scr_stats_bigint.c) may include this; everything else reads the snapshot + * through scr_lib.h's accessors. Keeping the layout in one header is what + * lets the bigint members live in their own unit, so a program that never + * reads them links none of that code. */ +#ifndef SCR_STATS_PRIVATE_H +#define SCR_STATS_PRIVATE_H + +#include +#include +#include + +/* The integers in the order Node's binding reports them. */ +enum { + SCR_STATS_DEV, + SCR_STATS_MODE, + SCR_STATS_NLINK, + SCR_STATS_UID, + SCR_STATS_GID, + SCR_STATS_RDEV, + SCR_STATS_BLKSIZE, + SCR_STATS_INO, + SCR_STATS_SIZE, + SCR_STATS_BLOCKS, + SCR_STATS_FIELDS +}; + +/* The timestamps, each kept as seconds + nanoseconds. */ +enum { SCR_STATS_ATIME, SCR_STATS_MTIME, SCR_STATS_CTIME, SCR_STATS_BIRTHTIME }; + +struct ScrStats { + size_t rc; + bool is_file; + bool is_dir; + bool is_symlink; /* lstat only — a followed stat never sees one */ + uint64_t fields[SCR_STATS_FIELDS]; /* blocks counts 512-byte units */ + int64_t sec[4]; + int64_t nsec[4]; +}; + +#endif /* SCR_STATS_PRIVATE_H */ diff --git a/tests/corpus/7018-bigint-stats.ts b/tests/corpus/7018-bigint-stats.ts new file mode 100644 index 000000000..540fa03a9 --- /dev/null +++ b/tests/corpus/7018-bigint-stats.ts @@ -0,0 +1,79 @@ +// BigIntStats: `statSync(path, { bigint: true })` and its siblings answer the +// same stat snapshot under Node's bigint member table. Every number member of +// Stats — the integers, the *Ms millisecond members and the BigIntStats-only +// *Ns nanosecond members — reads as a bigint; only the four time members stay +// Dates (their getTime() is a number). The literal option selects the table, +// and throwIfNoEntry: false keeps the BigIntStats | undefined union. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const posix = process.platform !== "win32"; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bigstats-")); +process.chdir(dir); +const stamp = 1700000100; // a fixed time: whole seconds, zero nanoseconds +fs.writeFileSync("data.txt", "0123456789"); +fs.utimesSync("data.txt", stamp, stamp); + +const st = fs.statSync("data.txt", { bigint: true }); + +console.log(typeof st.size, st.size, st.size === 10n); +console.log( + typeof st.dev, + typeof st.ino, + typeof st.mode, + typeof st.nlink, + typeof st.uid, + typeof st.gid, + typeof st.rdev, + typeof st.blksize, + typeof st.blocks, +); +console.log( + st.isFile(), + st.isDirectory(), + st.isSymbolicLink(), + st.isBlockDevice(), + st.isCharacterDevice(), + st.isFIFO(), + st.isSocket(), +); +console.log(typeof st.atimeMs, typeof st.mtimeNs, st.mtimeMs, st.mtimeNs); +console.log(st.mtimeNs === 1700000100000000000n, st.mtimeMs === 1700000100000n); +console.log(st.mtime instanceof Date, st.mtime.getTime() === 1700000100000); +console.log(st.mtimeMs === st.mtimeNs / 1000000n); +console.log(String(st.size), `${st.size}`, st.size + 1n, st.size * 2n, st.size.toString()); + +// The option literal picks the representation: omitting it is Stats. +console.log(typeof fs.statSync("data.txt").size, st.mode > 0n); +console.log(typeof fs.statSync("data.txt", { bigint: false }).size, st.mtimeNs > 0n); + +// lstatSync answers the link's own snapshot, still bigint-typed. +if (posix) { + fs.symlinkSync("data.txt", "link.txt"); + const link = fs.lstatSync("link.txt", { bigint: true }); + console.log(typeof link.size, link.size === 8n, link.isSymbolicLink(), link.isFile()); + console.log(typeof fs.lstatSync("data.txt", { bigint: true }).mtimeNs); +} + +// fstatSync over an open descriptor reads the same snapshot. +const fd = fs.openSync("data.txt", "r"); +const viaFd = fs.fstatSync(fd, { bigint: true }); +console.log( + typeof viaFd.size, + viaFd.size, + viaFd.ino === st.ino, + viaFd.mode === st.mode, + viaFd.mtimeNs === st.mtimeNs, +); +fs.closeSync(fd); + +// throwIfNoEntry: false keeps the union: a missing entry answers undefined. +const missing = fs.statSync("missing.txt", { bigint: true, throwIfNoEntry: false }); +console.log(missing === undefined); +const present = fs.statSync("data.txt", { bigint: true, throwIfNoEntry: false }); +if (present !== undefined) console.log(present.size, typeof present.ino, present.isFile()); + +// The Date members agree with the nanosecond members. +console.log(st.atime.getTime() === Number(st.atimeNs / 1000000n)); +console.log(st.ctime instanceof Date, typeof st.ctimeMs, typeof st.ctimeNs); diff --git a/tests/diagnostics/bigint-fences.ts b/tests/diagnostics/bigint-fences.ts index 0b8bcb4f2..0fd668cb3 100644 --- a/tests/diagnostics/bigint-fences.ts +++ b/tests/diagnostics/bigint-fences.ts @@ -9,6 +9,9 @@ console.log(JSON.stringify(1n)); import { statSync } from "node:fs"; const stats = statSync(".", { bigint: true }); +// A BigIntStats member is a call, not a value: reading one as a value still +// has no lowering. +const read = stats.isFile; console.log(stats.size); void typed; diff --git a/tests/harness/__snapshots__/bigint-fences.ts.txt b/tests/harness/__snapshots__/bigint-fences.ts.txt index a33eb4ee6..3df7b8c95 100644 --- a/tests/harness/__snapshots__/bigint-fences.ts.txt +++ b/tests/harness/__snapshots__/bigint-fences.ts.txt @@ -23,20 +23,18 @@ bigint-fences.ts:8:28 - error SC1090: JSON.stringify of 'bigint' values (only nu | ^~ 9 | -bigint-fences.ts:11:39 - error SC2020: 'statSync with bigint: true' is part of the standard library types but has no scriptc lowering yet +bigint-fences.ts:14:14 - error SC1090: BigIntStats methods as values (call 'isFile' directly) are not supported yet - 10 | import { statSync } from "node:fs"; - 11 | const stats = statSync(".", { bigint: true }); - | ^~~~ - 12 | console.log(stats.size); + 13 | // has no lowering. + 14 | const read = stats.isFile; + | ^~~~~~~~~~~~ + 15 | console.log(stats.size); - hint: BigIntStats has no lowering; omit bigint for Stats +bigint-fences.ts:17:6 - error SC2004: uses of 'typed' inherit the blocker on its declaration -bigint-fences.ts:14:6 - error SC2004: uses of 'typed' inherit the blocker on its declaration - - 13 | - 14 | void typed; + 16 | + 17 | void typed; | ^~~~~ - 15 | void dynamicValue; + 18 | void dynamicValue; hint: the declaration of 'typed' 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/7018-bigint-stats.ts.json b/tests/harness/corpus-costs/7018-bigint-stats.ts.json new file mode 100644 index 000000000..d676e8919 --- /dev/null +++ b/tests/harness/corpus-costs/7018-bigint-stats.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 25000 } From 7245ec9a0416454ae9b9c5157ff47a209725c639 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:40:37 -0700 Subject: [PATCH 22/47] Admit function values whose completed parameters the slot coercion adapts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method declaring `ctx?: Ctx` completes to `(id, Ctx | undefined) => string`; a slot that requires the narrower `(id, ToolCtx) => string` was refused with SC1090 at the value's birth, because the gate only compared the two signatures for equality. The slot coercion (funcCoerceAdapter) already converts every parameter through coercibleValue, the record width conversion (which also selects a width-liftable arm of a union destination — the `ToolCtx` into `Ctx | undefined` shape), the partial checked-dynamic extraction, and the union arm preservation. `funcSlotAdaptable` now asks the same question through `cleanParamPair`, so the gate admits exactly the pairs the adapter converts without a trap-only path, and both call sites (the object-literal method slot and the mapped value target) use it. The gate also learns the two slot shapes it could not name: an object literal contextualized by an INDEX SIGNATURE (`Record`) takes its member slot from the index signature's value type, and a literal with no contextual type takes the member's own completed type — the type every later read of the member spells. Nested function DECLARATIONS are no longer judged at birth: lowering a declaration builds the body, not a value, and each value use re-checks the completed signature against that use's own target. An overloaded nested declaration with an optional parameter, returned as a value (`return write` into a one-parameter `Writer`), is now accepted instead of refused. Corpus: tests/corpus/optional-param-function-values.ts (a required slot over an optional parameter, an index-signature member with an extra trailing optional parameter, an annotated value with a defaulted parameter, the nested overloaded declaration returned as a value, direct-call controls, and a union-typed slot). Node-identical stdout. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 58 +++++++--- .../lowering/coercions/function-adapters.ts | 52 +++++++++ .../src/frontend/lowering/lower-functions.ts | 10 +- .../optional-param-function-values.ts.json | 6 + .../tests/diagnostics/function-forms.ts.json | 6 - .../corpus/optional-param-function-values.ts | 107 ++++++++++++++++++ tests/diagnostics/function-forms.ts | 54 --------- .../__snapshots__/function-forms.ts.txt | 6 - .../optional-param-function-values.ts.json | 1 + 9 files changed, 214 insertions(+), 86 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-param-function-values.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json create mode 100644 tests/corpus/optional-param-function-values.ts delete mode 100644 tests/diagnostics/function-forms.ts delete mode 100644 tests/harness/__snapshots__/function-forms.ts.txt create mode 100644 tests/harness/corpus-costs/optional-param-function-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 6d1e97f8c..45329d077 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -20,7 +20,7 @@ import { isUnitType, typeEquals, } from "../../ir/ir.js"; -import { isJsSourceFile, isNodeEsmFile } from "../program.js"; +import { isJsSourceFile, isNodeEsmFile, locOf } from "../program.js"; import { genResultRecord, restTupleParamTypes, @@ -36,7 +36,7 @@ import { isStreamUndefCallExpr } from "./lower-server.js"; import { nsPathPrefix } from "./lower-namespaces.js"; import { declSymbolOf } from "./lower-modules.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; -import { restPackingAdapterFits } from "./coercions/function-adapters.js"; +import { funcSlotAdaptable, restPackingAdapterFits } from "./coercions/function-adapters.js"; import { implicitMonoFile, implicitAnyParamSymbolsOf } from "./generic-functions.js"; /** How a parameter participates in CALL-SITE COMPLETION (the frontend @@ -668,21 +668,6 @@ export function requireExactArityValue( // optional/defaulted params spell their `T | undefined` slots (mapType's // completed-signature contract), so omitted trailing args complete with // the undefined arm like any direct call. - // A nested function declaration whose only non-required parameter is a - // typed rest: its value carries the typed-rest ABI its own type spells, - // and every use converts it at its slot (same signature, or the rest - // packing adapter for fixed parameters). - if ( - contextual === null && - ts.isFunctionDeclaration(blame) && - blame.name !== undefined && - funcType.kind === "func" && - funcType.restAbi === "typed" && - shapes.every((s) => s.mode === "required" || s.mode === "rest") - ) { - const own = lowerer.mapTypeOf(lowerer.typeOf(blame.name)); - if (own && typeEquals(own, funcType)) return; - } const target = contextual ? lowerer.checker.getContextualType(contextual) : undefined; const mapped = target ? lowerer.mapTypeOf(target) @@ -722,8 +707,33 @@ export function requireExactArityValue( const member = literalType ? lowerer.checker.getPropertyOfType(literalType, blame.name.text) : undefined; - const slot = member ? lowerer.memberSlotTypeOf(lowerer.checker.getTypeOfSymbol(member)) : null; + let slot = member ? lowerer.memberSlotTypeOf(lowerer.checker.getTypeOfSymbol(member)) : null; + // A literal contextualized by an INDEX SIGNATURE (`Record`) names no member for the method: its slot is the index + // signature's value type. + if (!slot && literalType) { + const index = lowerer.checker.getIndexInfosOfType(literalType)[0]; + if (index) slot = lowerer.memberSlotTypeOf(index.type); + } if (slot && typeEquals(slot, funcType)) return; + // The slot may pass parameters the method's completed ABI cannot take + // verbatim (a required narrower interface parameter where the method + // declares `ctx?: Ctx`, completing to `Ctx | undefined`): the slot + // coercion adapts the pair like any other function value. + if ( + slot?.kind === "func" && + funcType.kind === "func" && + funcSlotAdaptable(lowerer, funcType, slot, locOf(blame)) + ) + return; + // An UNCONTEXTUALIZED literal's own inferred type is what every later + // read of the member spells, and it is built from the method's own + // completed signature (mapType's completed-signature contract), so the + // method's own completed type is its slot. + if (!slot) { + const own = lowerer.mapTypeOf(lowerer.typeOf(blame)); + if (own && typeEquals(own, funcType)) return; + } } // A union-typed slot (`runner || defaultRunner` under a // `CommandRunner | undefined` context): the value can only inhabit @@ -770,6 +780,18 @@ export function requireExactArityValue( ) { return; } + // A signature the slot's coercion adapts without a trap-only path: an + // extra trailing OPTIONAL parameter the slot never passes (forwarded as + // undefined, JavaScript's surplus-argument rule) or a parameter pair the + // adapter converts (a required narrower interface parameter against the + // value's `Ctx | undefined` completed slot). + if ( + mappedFn?.kind === "func" && + funcType.kind === "func" && + funcSlotAdaptable(lowerer, funcType, mappedFn, locOf(blame)) + ) { + return; + } // An uncontextualized npm-static implementation function can still // have a declaration-backed completed ABI: the package's .d.ts // projection supplies the body-facing parameter/return types while diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 78c273b66..44bca35e1 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -134,6 +134,57 @@ export function cleanFuncAdaptable( return src.ret.kind === "void" && dst.ret.kind === "dyn"; } +/** Whether the slot's argument type `actual` converts into the function's + * parameter type `expected` without a trap-only path: the pieces + * funcCoerceAdapter's parameter loop converts (coercibleValue, the record + * width conversion — which also selects a width-liftable arm of a union + * destination, the required-narrower-argument shape — the partial + * checked-dynamic extraction, and the union arm preservation). */ +function cleanParamPair(lowerer: Lowerer, actual: IrType, expected: IrType): boolean { + if (lowerer.coercibleValue(actual, expected)) return true; + if (lowerer.widthLiftPlan(actual, expected) !== null) return true; + if (actual.kind === "union" && expected.kind === "dyn") + return ( + lowerer.unions.get(actual.unionId)?.arms.some((arm) => lowerer.dynConvertible(arm)) ?? false + ); + if (actual.kind === "union" && expected.kind === "union") { + const target = lowerer.unions.get(expected.unionId); + return ( + target !== undefined && target.arms.every((arm) => lowerer.armTag(actual.unionId, arm) >= 0) + ); + } + return false; +} + +/** Whether a function VALUE of `src` may inhabit a `dst` slot: the slot's + * coercion (funcCoerceAdapter) forwards its arguments, converts them, and + * converts the result — with no trap-only (stranded) path. This is + * cleanFuncAdaptable extended to the source-with-MORE-parameters shape: a + * value carrying extra trailing parameters whose completed ABI holds + * undefined (a `(id, params, signal, onUpdate, ctx?: Ctx)` lambda in a + * four-parameter slot). A call through the slot never passes them, so the + * coercion forwards undefined — JavaScript's surplus-argument rule. */ +export function funcSlotAdaptable( + lowerer: Lowerer, + src: IrType & { kind: "func" }, + dst: IrType & { kind: "func" }, + loc: SrcLoc, +): 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++) { + const param = src.params[i]!; + if (omittedArgFor(lowerer, param, loc) === null && !typeEquals(param, UNDEFINED_T)) + 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; + } + 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 a typed-rest function (`(...m: T[]) => R`) can serve a slot * with fixed parameters (`(m: T) => void`): the slot's parameters convert * to the function's fixed parameters, then to the rest element type. */ @@ -348,6 +399,7 @@ export function funcCoerceAdapter( continue; } } + if (cleanParamPair(lowerer, actual, expected)) continue; strandParams = true; } let voidRet: "dyn" | "strand" | null = null; diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index e29483f22..0b6526039 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -298,9 +298,15 @@ export function lowerLambda( // A lambda IS a value: the completed-ABI rule applies at birth. The // contextual (target) type decides — `(x?: number) => void` may flow // into a slot annotated `(x: number | undefined) => void` (same ABI - // signature), anything else is fenced. Nested function declarations and - // object-literal shorthand methods aren't expressions — always fenced. + // signature), anything else is fenced. Object-literal shorthand methods + // aren't expressions: their slot is the literal's, judged inside. + // A nested function DECLARATION is not an expression either: lowering it + // builds the body, not a value, and every value use re-runs the same + // fence at the reference (identifiers.ts) with that use's own target + // (a call, or `const g = f` under an annotated slot). Fencing it here + // refused declarations that are only ever CALLED. if ( + !ts.isFunctionDeclaration(node) && !( ts.isMethodDeclaration(node) && isJsSourceFile(ts.getSourceFileOfNode(node)) && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-param-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-param-function-values.ts.json new file mode 100644 index 000000000..cd7d1520d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-param-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/optional-param-function-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json deleted file mode 100644 index 7ca950ac1..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/function-forms.ts" - ], - "diags": [] -} diff --git a/tests/corpus/optional-param-function-values.ts b/tests/corpus/optional-param-function-values.ts new file mode 100644 index 000000000..716c07409 --- /dev/null +++ b/tests/corpus/optional-param-function-values.ts @@ -0,0 +1,107 @@ +// Functions whose own parameters are optional, defaulted or extra take part +// as VALUES in slots that spell the completed signature (WP-A, the +// `tool-definition-wrapper` shape): a method's narrower required parameter +// against the declaring interface's optional one, a handler with more +// (trailing optional) parameters than the slot's type names, and a nested +// function declaration returned in an object literal. Node semantics: the +// slot passes fewer arguments than the value declares, so the missing ones +// are `undefined` and defaults apply. + +interface Ctx { + cwd: string; +} + +interface ToolCtx extends Ctx { + tools: string[]; +} + +interface RequiredSlot { + run(id: string, ctx: ToolCtx): string; +} + +// A: a method declaring its parameter OPTIONAL fills a slot that requires +// the (narrower) parameter: the slot always passes it, so the `undefined` +// arm is unreachable through that slot. +const a: RequiredSlot = { + run(id: string, ctx?: Ctx): string { + return `${id}@${ctx === undefined ? "-" : ctx.cwd}`; + }, +}; +console.log(a.run("A", { cwd: "/a", tools: [] })); + +// The same method through its own object: the omitted call is real here. +const aLiteral = { + run(id: string, ctx?: Ctx): string { + return `${id}@${ctx === undefined ? "-" : ctx.cwd}`; + }, +}; +console.log(aLiteral.run("A"), aLiteral.run("A", { cwd: "/a" })); + +// B: a handler with an EXTRA trailing optional parameter in a two-parameter +// slot: the slot never passes it, so it reads undefined. +type Handler = (id: string, payload: number) => string; +const handlers: Record = { + plain(id, payload, extra?: string) { + return `${id}:${payload}:${extra === undefined ? "-" : extra}`; + }, +}; +console.log(handlers["plain"]("one", 1)); + +const annotated: Handler = (id, payload, extra?: string) => + `${id}:${payload}:${extra === undefined ? "-" : extra}`; +console.log(annotated("two", 2)); + +// A defaulted trailing parameter: the slot's omission applies the default. +const withDefault: Handler = (id, payload, extra = "dflt") => `${id}:${payload}:${extra}`; +console.log(withDefault("three", 3)); + +// C: a nested function DECLARATION (overloaded, with an optional parameter) +// returned as a value: lowering the declaration builds its body; the value +// use at the return re-checks the completed signature against the slot. +interface Writer { + (text: string): string; +} +function makeWriter(prefix: string): Writer { + function write(text: string): string; + function write(text: string, times?: number): string; + function write(text: string, times?: number): string { + const n = times === undefined ? 1 : times; + let out = ""; + for (let i = 0; i < n; i++) out += `${prefix}${text}`; + return out; + } + return write; +} +const writer = makeWriter("> "); +console.log(writer("x")); + +// Control: the same signatures through direct calls. +function greet(name?: string): string { + return name === undefined ? "hello" : `hello ${name}`; +} +console.log(greet(), greet("ada")); +function repeat(text: string, times = 3, sep = "|"): string { + let out = ""; + for (let i = 0; i < times; i++) out += (i === 0 ? "" : sep) + text; + return out; +} +console.log(repeat("z"), repeat("z", 2), repeat("z", 2, "+")); +const pick: (x: number | undefined) => number = (x?: number) => (x === undefined ? -1 : x); +console.log(pick(undefined), pick(4)); + +// A method whose optional parameter is READ through the value it was given +// (the `arguments.length`-free shape the tool registry uses). +interface UnsafeSlot { + call(payload: string): string; +} +const unsafe: UnsafeSlot = { + call(payload: string, options?: { tag: string }): string { + return `${payload}/${options === undefined ? "-" : options.tag}`; + }, +}; +console.log(unsafe.call("q")); + +// A value stored in a union-typed slot (the omitted call stays observable). +type Runner = RequiredSlot | undefined; +const maybeRunner: Runner = a; +console.log(maybeRunner === undefined ? "none" : maybeRunner.run("U", { cwd: "/u", tools: [] })); diff --git a/tests/diagnostics/function-forms.ts b/tests/diagnostics/function-forms.ts deleted file mode 100644 index 55d717414..000000000 --- a/tests/diagnostics/function-forms.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Overload signatures compile now (they lower to nothing; calls flow -// through the implementation's ABI — corpus 1850/1851); kept here as -// working context for the fences below. -function overloaded(a: number): number; -function overloaded(a: string): string; -function overloaded(a: number | string): number | string { - return a; -} -// Type-parameter defaults compile now (bindings fill from the mapped -// default — corpus 1950); kept as working context for the fences below. -function defaulted_tp(x: T): T { - return x; -} -console.log(defaulted_tp(1)); -// Optional/default/rest parameters compile for direct calls. Typed rest -// functions are first-class values too; the remaining fence below is for a -// nested defaulted declaration with no contextual completed signature. -function optional(a?: number): number { - return a === undefined ? 0 : a; -} -function defaulted(a: number = 1): number { - return a; -} -function rest(...args: number[]): number { - return args.length; -} -// Optional/defaulted functions as values compile when the inferred binding -// spells the completed `T | undefined` signature (corpus 1535); typed rest -// signatures carry a packed-array ABI (corpus 2917). -const restRef = rest; -// Union-typed parameter defaults compile now (corpus 1535); kept here as -// working context for the fences below. -function unionDefault(x: string | null = "hi"): void { - console.log(x === null); -} -// Nested function declarations are values (const name = lambda) — fenced. -function outer(): number { - function inner(n = 2): number { - return n; - } - return inner(1) + inner(); -} -// Spread into a REST parameter lowers (the pack copies the spread's -// elements); the object and array spread shapes are covered by the -// differential corpus. -const pair = [1, 2]; -console.log(rest(...pair)); -unionDefault(); -console.log(optional(1), defaulted(), rest(1, 2), outer()); - -// Reached: collection defers its diagnostics until a reference makes -// them relevant; these references are what makes them count. -overloaded(1); -defaulted_tp(1); diff --git a/tests/harness/__snapshots__/function-forms.ts.txt b/tests/harness/__snapshots__/function-forms.ts.txt deleted file mode 100644 index 3c3bd5c83..000000000 --- a/tests/harness/__snapshots__/function-forms.ts.txt +++ /dev/null @@ -1,6 +0,0 @@ -function-forms.ts:38:3 - error SC1090: functions with optional or defaulted parameters as values, except where the target type spells the completed signature with required parameters (a '(x?: T) => R' function flows into a '(x: T | undefined) => R' slot, and a package/'any' slot takes any natively representable signature; otherwise call the function directly) are not supported yet - - 37 | function outer(): number { - 38 | function inner(n = 2): number { - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 39 | return n; \ No newline at end of file diff --git a/tests/harness/corpus-costs/optional-param-function-values.ts.json b/tests/harness/corpus-costs/optional-param-function-values.ts.json new file mode 100644 index 000000000..57d1f5243 --- /dev/null +++ b/tests/harness/corpus-costs/optional-param-function-values.ts.json @@ -0,0 +1 @@ +{ "plain": 2950, "san": 4150 } From 6e396ad721a39a9b599465d0aa1539e4c29a43fd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:40:41 -0700 Subject: [PATCH 23/47] Read a shared member off a receiver the checker ruled unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exhaustive switch's `default:` branch declares `const unreachable: never = event`, and the warning reporter reads a member off that value through an erasure (`(event as any).type`, `(event as unknown as { type: string }).type`) — the shape pi's proxy warning takes. The union shared-field read refused it because the checker types the receiver `never` (or the erasure), not the union the value still holds. `lowerUnionProperty` now proceeds when the receiver expression is a `never` value, directly or through the parenthesized/parenthesized-assertion chain that escapes it, and reads the field off what the lowered value actually is: the shared-field read for the union it holds, the record read for a record, the keyed read for checked-dynamic. `never` admits no reachable call site, so no reachable program changes shape. Corpus: tests/corpus/erased-never-member-read.ts (`as any`, `as unknown as`, and a helper function taking the same shape). Node-identical stdout. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 36 ++++++++++++- .../corpus/erased-never-member-read.ts.json | 6 +++ tests/corpus/erased-never-member-read.ts | 54 +++++++++++++++++++ .../erased-never-member-read.ts.json | 1 + 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json create mode 100644 tests/corpus/erased-never-member-read.ts create mode 100644 tests/harness/corpus-costs/erased-never-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 c3eb31800..a9aed1804 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3424,6 +3424,26 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi return null; } +/** Whether a receiver is a value the checker ruled UNREACHABLE (`never`), + * directly or through the parenthesized assertion chain that escapes it + * (`(x as any)`, `(x as unknown)`, `x!`). The erasure is what hides the + * value's stored union from the checker; the value itself still holds it. */ +function unreachableReceiver(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + let node: ts.Expression = expr.expression; + for (;;) { + if ((lowerer.typeOf(node).flags & ts.TypeFlags.Never) !== 0) return true; + if ( + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node) || + ts.isAssertionExpression(node) + ) { + node = node.expression; + continue; + } + return false; + } +} + /** Shared-field read `r.f` on a UNION receiver: supported exactly when * every arm is a record/class possessing the field with ONE shared IR * type — the discriminant pattern (`r.kind`, primitive) and the @@ -3441,14 +3461,26 @@ export function lowerUnionProperty( const receiverIr = structuralReadType(lowerer, expr.expression) ?? lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - if (receiverIr?.kind !== "union") return null; + // 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 + // checked-dynamic, hiding the union the value actually holds. Node reads + // the member off the object it is; the arms all declare it, so the read + // is the shared-field read. `never` admits no reachable call site, so no + // reachable program changes shape. + const unreachable = receiverIr?.kind !== "union" && unreachableReceiver(lowerer, expr); + if (receiverIr?.kind !== "union" && !unreachable) return null; // Lower the receiver first: its runtime value can still carry the full // binding union even when the checker has removed several variants. let value = lowerer.lowerExpr(expr.expression); // A guard can remove several variants without selecting one record. // Materialize that sub-union before looking for common fields; arms // ruled out by the guard must not veto a field shared by the survivors. - if (value.type.kind === "union" && !typeEquals(value.type, receiverIr)) { + if ( + receiverIr?.kind === "union" && + value.type.kind === "union" && + !typeEquals(value.type, receiverIr) + ) { const helper = lowerer.narrowedRetagHelper( expr.expression, value.type.unionId, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json new file mode 100644 index 000000000..82e7ed434 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/erased-never-member-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/erased-never-member-read.ts b/tests/corpus/erased-never-member-read.ts new file mode 100644 index 000000000..c8d19edb5 --- /dev/null +++ b/tests/corpus/erased-never-member-read.ts @@ -0,0 +1,54 @@ +// An `any`-member read off a receiver the checker narrowed to `never` (the +// default branch of a switch that exhausts every arm): `const _x: never = +// event` proves the branch unreachable to the checker, and the `as any` +// erasure that escapes it hides the union the value still holds. Node reads +// the property off the object it is, so the read is the union's shared +// field. + +type Event = { type: "start"; at: number } | { type: "stop"; at: number; reason: string }; + +function describe(event: Event): string { + switch (event.type) { + case "start": + return `start ${event.at}`; + case "stop": + return `stop ${event.at} ${event.reason}`; + default: { + const unreachable: never = event; + void unreachable; + return `unhandled ${(event as any).type as string}`; + } + } +} + +console.log(describe({ type: "start", at: 1 })); +console.log(describe({ type: "stop", at: 2, reason: "done" })); + +// The same read under an `unknown` erasure and a `!` assertion, and through +// a helper function (the shape a warning reporter takes). +function unhandled(event: Event): string { + switch (event.type) { + case "start": + return "s"; + case "stop": + return "t"; + default: + return `unknown event ${(event as unknown as { type: string }).type}`; + } +} +console.log(unhandled({ type: "start", at: 1 }), unhandled({ type: "stop", at: 2, reason: "r" })); + +// The same read through an `as any` helper that names the field explicitly +// and through the value's OTHER shared field, so the union's layout is not +// the reason either read succeeds. +function kind(event: Event): string { + switch (event.type) { + case "start": + return "first"; + case "stop": + return "second"; + default: + return `kind:${(event as any).type}`; + } +} +console.log(kind({ type: "start", at: 7 }), kind({ type: "stop", at: 8, reason: "x" })); diff --git a/tests/harness/corpus-costs/erased-never-member-read.ts.json b/tests/harness/corpus-costs/erased-never-member-read.ts.json new file mode 100644 index 000000000..6cf92fddb --- /dev/null +++ b/tests/harness/corpus-costs/erased-never-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 4100 } From 83e26da25e7882083f92d630ea68d3b6ca7a2b3c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:19:11 -0700 Subject: [PATCH 24/47] Keep a literal's accessors live through a declared data member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object literal whose get/set accessor satisfies a member the target type declares as DATA was refused with SC1090: a record member is one storage slot, so the accessor could only be snapshotted at construction. Node keeps the accessor live, and the pi sites that hit this (agent.ts:90, runner.ts:889, codemode/execution.ts:562) are all live getters — one throws until the object observes later state — so a snapshot is disqualifying. One static record layout cannot hold both member kinds at one name, so the analysis marks the TARGET type instead of inventing a literal-only variant: when a literal's member kind disagrees with the member the contextual type declares at the same name, that type keeps checked identity, exactly as a structural type that carries a class instance's identity does. The literal then lowers through the checked-dynamic literal path, which defines accessor members as live get/set properties and data members as plain properties, and reads, writes, Object.keys, `in` and JSON.stringify on the declared type dispatch dynamically — Node's own model. The mirrored direction is closed too: a data property satisfying a declared accessor member (`interface Live { get size(): number }`) used to be an SC2002 missing-`%get:`-slot refusal; it now keeps the declared type's identity as well. Cost, the accepted trade: every value of a MARKED type becomes a checked dynamic view — a property table and closure dispatch instead of a static struct slot, so reads and writes on those types are slower (never wrong). The cost is scoped to the marked type: a program with no such literal marks nothing, and the frontend analysis itself adds only one array push per object literal and one Set lookup per data member, with no checker query unless a member name is an accessor somewhere in the program. IR is byte-identical for 64 existing corpus programs compiled before and after. The whole-type accessor-shape form (static reads, closure slots) remains available as the faster representation if profiles ever show this matters; it needs the accessor enumeration surfaces (keys, JSON, inspect, for-in, spread, rest) that this route gets for free. Only a single object type can be the literal's target: a multi-arm union picks its arm from the value itself and an intersection merges members, so neither is marked and both keep today's behavior. A value that reaches the slot through a variable rather than a literal keeps today's refusal. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/class-protocols.ts | 130 +++++++++++++++--- .../test/class-protocol-boundaries.test.ts | 55 ++++++++ ...8423-accessor-member-kind-mismatch.ts.json | 6 + .../8423-accessor-member-kind-mismatch.ts | 93 +++++++++++++ ...8423-accessor-member-kind-mismatch.ts.json | 1 + 5 files changed, 268 insertions(+), 17 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json create mode 100644 tests/corpus/8423-accessor-member-kind-mismatch.ts create mode 100644 tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 15fd83bdf..e120d07c7 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -32,7 +32,19 @@ export class ClassProtocols { }; const usesReceiver = (node: ts.Node): boolean => node.kind === ts.SyntaxKind.ThisKeyword || ts.forEachChild(node, usesReceiver) === true; + // The names any type in the program declares as a get/set accessor, and + // every object literal in it. A data member of a literal can only + // disagree with a target at a name in this set, and marking waits until + // the walk ends, so the checker round trip stays off every literal whose + // names are not accessors somewhere (this analysis runs on every + // program, so it must stay syntax-cheap). + const accessorNames = new Set(); + const literals: ts.ObjectLiteralExpression[] = []; const visit = (node: ts.Node): void => { + if (ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { + const name = propertyNameText(node.name); + if (name !== null) accessorNames.add(name); + } if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { for (const clause of node.heritageClauses?.nodes ?? []) if (clause.token === ts.SyntaxKind.ImplementsKeyword) @@ -56,31 +68,107 @@ export class ClassProtocols { this.methods.set(ts.symbolName(property), entries); } } - if ( - ts.isObjectLiteralExpression(node) && - node.properties.nodes.some( - (property) => - (ts.isMethodDeclaration(property) || - ts.isGetAccessorDeclaration(property) || - ts.isSetAccessorDeclaration(property)) && - property.body !== undefined && - usesReceiver(property.body), - ) - ) { - // Receiver-dependent methods and accessors need a live object even - // without a class implementation. Receiver-free methods keep their - // native callable fields, including async callbacks. - add(this.checker.getTypeAtLocation(node)); - const contextual = this.checker.getContextualType(node); - if (contextual) add(contextual); + if (ts.isObjectLiteralExpression(node)) { + if ( + node.properties.nodes.some( + (property) => + (ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) || + ts.isSetAccessorDeclaration(property)) && + property.body !== undefined && + usesReceiver(property.body), + ) + ) { + // Receiver-dependent methods and accessors need a live object even + // without a class implementation. Receiver-free methods keep their + // native callable fields, including async callbacks. + add(this.checker.getTypeAtLocation(node)); + const contextual = this.checker.getContextualType(node); + if (contextual) add(contextual); + } + // Member-kind disagreements with the literal's target type are + // checked once the walk ends, when the accessor-name index above is + // complete (see markMemberKindViews). + literals.push(node); } ts.forEachChild(node, visit); }; for (const source of this.sources) if (!source.isDeclarationFile) visit(source); + for (const literal of literals) this.markMemberKindViews(literal, accessorNames, add); this.markIdentityFlows(protocols); return protocols; } + /** Marks the structural types a literal's member kinds disagree with: a + * get/set accessor satisfying a declared DATA member, or a data property + * satisfying a declared accessor member. One static record layout cannot + * hold both member kinds at one name (a data member is a storage slot, an + * accessor member a `%get:`/`%set:` closure pair), so the target type + * keeps checked identity instead: every value of it is a live + * checked-dynamic view, where the accessor stays live, `Object.keys` + * lists it, and `JSON.stringify` calls it — Node's own model. + * + * Only the TARGET is marked: the literal then takes the checked-dynamic + * literal path (`lowerDynObjectLiteral`), which defines an accessor member + * as a live get/set property and a data member as a plain property, so + * both directions construct live objects. */ + private markMemberKindViews( + literal: ts.ObjectLiteralExpression, + accessorNames: ReadonlySet, + add: (type: ts.Type) => void, + ): void { + const members: { name: string; accessor: boolean }[] = []; + for (const property of literal.properties.nodes) { + if (ts.isSpreadAssignment(property) || property.name === undefined) continue; + const name = propertyNameText(property.name); + if (name === null) continue; + if (ts.isGetAccessorDeclaration(property) || ts.isSetAccessorDeclaration(property)) { + members.push({ name, accessor: true }); + continue; + } + if ( + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + // A data member only disagrees with an accessor-declared target, and + // only a type that declares an accessor somewhere can be one: the + // name index keeps the checker round trip off the common literal. + accessorNames.has(name) + ) + members.push({ name, accessor: false }); + } + if (members.length === 0) return; + const contextual = attempt(() => this.checker.getContextualType(literal)); + if (contextual === undefined) return; + // Only one object type can be the one the literal converts into: a union + // with several arms picks its arm from the value itself, and an + // intersection merges members, so neither has the single layout the + // literal would have to disagree with. Nullish arms are ignored, so + // `Counter | undefined` still names Counter. + const present = contextual.isUnionType() + ? ts + .constituentTypes(contextual) + .filter((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0) + : [contextual]; + const target = present.length === 1 ? present[0]! : undefined; + if (target === undefined || target.isIntersectionType()) return; + if ((target.flags & ts.TypeFlags.TypeParameter) !== 0) return; + if (!this.isStructuralTarget(target)) return; + const disagrees = members.some((member) => { + const kind = attempt(() => this.memberKind(target, member.name)); + return kind == null ? false : (kind === "accessor") !== member.accessor; + }); + if (disagrees) add(target); + } + + /** The member kind `type` declares at `name` — an accessor (get/set pair) + * or a data property — or null when it declares no member by that name. */ + private memberKind(type: ts.Type, name: string): "accessor" | "data" | null { + const property = this.checker.getPropertyOfType(type, name); + if (property === undefined) return null; + return property.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor) + ? "accessor" + : "data"; + } + /** Structural types that receive a value with identity: a program class * instance, or a value of a type already marked here. Natively such a * type would be a record, and the conversion into it a field copy, so a @@ -1189,6 +1277,14 @@ function attempt(query: () => T): T | undefined { } } +/** The static property name a member declares, or null for a computed key + * (its name is not a compile-time fact). */ +function propertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text; + if (ts.isNumericLiteral(name)) return String(Number(name.text)); + return null; +} + /** `(value as T).member` and `(value as T)[key]`: the assertion is read or * written member-wise in place, which lowers to the instance's own fields * and creates no view that could outlive it. */ diff --git a/packages/compiler/test/class-protocol-boundaries.test.ts b/packages/compiler/test/class-protocol-boundaries.test.ts index 62fcb15f5..6ed15a410 100644 --- a/packages/compiler/test/class-protocol-boundaries.test.ts +++ b/packages/compiler/test/class-protocol-boundaries.test.ts @@ -30,3 +30,58 @@ console.log(read(new Value()));`, rmSync(directory, { recursive: true, force: true }); } }); + +test("an accessor satisfying a declared data member keeps the target live", async () => { + const directory = mkdtempSync(join(tmpdir(), "scriptc-accessor-data-member-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync( + entry, + `interface Counter { size: number; label: string; } +function makeCounter(): Counter { + let current = 1; + return { + get size(): number { return current; }, + set size(next: number) { current = next; }, + label: "counter", + }; +} +const counter = makeCounter(); +counter.size = 10; +console.log(counter.size, counter.label, Object.keys(counter).join(","));`, + ); + const result = await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(result.ok).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("a data property satisfying a declared accessor keeps the target live", async () => { + const directory = mkdtempSync(join(tmpdir(), "scriptc-data-accessor-member-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync( + entry, + `interface Live { readonly label: string; get size(): number; } +function makeLive(): Live { return { label: "counter", size: 3 }; } +console.log(makeLive().size);`, + ); + const result = await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(result.ok).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json new file mode 100644 index 000000000..15728a3da --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-accessor-member-kind-mismatch.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8423-accessor-member-kind-mismatch.ts b/tests/corpus/8423-accessor-member-kind-mismatch.ts new file mode 100644 index 000000000..4cdc3136c --- /dev/null +++ b/tests/corpus/8423-accessor-member-kind-mismatch.ts @@ -0,0 +1,93 @@ +// An object literal whose get/set accessor satisfies a declared DATA member +// keeps the accessor live through the declared type, as in Node: every read +// calls the getter, a write calls the setter, Object.keys lists the accessor +// name in declaration order, and JSON.stringify calls the getter. The +// mirrored direction (a data property satisfying a declared accessor member) +// keeps the declared accessor's identity too. + +interface Counter { + label: string; + size: number; +} + +// A recomputed getter and a setter that both observe later state. +function makeCounter(label: string): Counter { + let value = 1; + return { + get size(): number { + console.log("get size"); + return value; + }, + set size(next: number) { + console.log("set size", next); + value = next * 2; + }, + label, + }; +} + +// The value passes onward to other typed slots of the declared type. +function readTwice(counter: Counter): number { + return counter.size + counter.size; +} + +function bump(counter: Counter): void { + counter.size = counter.size + 1; +} + +const counter = makeCounter("counter"); +console.log("read", counter.size, counter.label); +bump(counter); +console.log("stored", counter.size); +console.log("keys", Object.keys(counter).join(",")); +console.log("json", JSON.stringify(counter)); +console.log("twice", readTwice(counter)); + +// A getter that throws until the object observes later state. +interface Gate { + value: number; +} + +interface GateHost { + gate: Gate; + open: () => void; +} + +function makeGate(): GateHost { + let open = false; + const gate: Gate = { + get value(): number { + if (!open) throw new Error("inactive"); + return 7; + }, + }; + return { gate, open: () => (open = true) }; +} + +const host = makeGate(); +try { + console.log(host.gate.value); +} catch (error) { + console.log("threw", error instanceof Error ? error.message : "?"); +} +host.open(); +console.log("open", host.gate.value, host.gate.value); + +// The mirrored direction: a data literal satisfying a declared accessor. +interface Live { + readonly label: string; + get size(): number; +} + +function readLive(live: Live): string { + return live.label + ":" + live.size; +} + +function decorate(label: string, size: number): Live { + return { label, size }; +} + +const live = decorate("plain", 5); +console.log("live", readLive(live)); +console.log("live-keys", Object.keys(live).join(",")); +console.log("live-json", JSON.stringify(live)); diff --git a/tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json b/tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json new file mode 100644 index 000000000..91df8199c --- /dev/null +++ b/tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 3800 } From caf098eff9acdd1b006bb9079538c280346b655b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:22:50 -0700 Subject: [PATCH 25/47] Refuse a stdlib-global snapshot read as a value `const proc = globalThis.process` lowers to alias plumbing: the declaration emits no storage and no code, because member and call reads resolve through the global it names. A read that needs the VALUE therefore failed at the use with the generic "binding form with no lowering", which names neither the declaration nor the cause. Register every stdlib-global snapshot declaration (TypeScript sources only, matching the elision) and report the cause at the declaration (SC2020), naming the global and, when it has a short spelling, the type the program asked the compiler to hold. The use site keeps the inherited-blocker cascade (SC2004) instead of the generic text. An unread snapshot stays elided: reading a global has no side effects, so dropping an unread `const p = process` is observationally identical to Node, and refusing it would break programs that compile harmlessly today. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/diagnostics/diagnostic.ts | 27 +++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 45 +++++++++++++++++++ .../src/frontend/lowering/surfaces.ts | 9 ++++ .../stdlib-snapshot-value-use.ts.json | 6 +++ ...unmappable-declaration-initializer.ts.json | 6 +++ tests/corpus/990-process-basics.ts | 7 +++ .../diagnostics/stdlib-snapshot-value-use.ts | 10 +++++ .../unmappable-declaration-initializer.ts | 10 +++++ .../stdlib-snapshot-value-use.ts.txt | 17 +++++++ .../unmappable-declaration-initializer.ts.txt | 8 ++++ 10 files changed, 145 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json create mode 100644 tests/diagnostics/stdlib-snapshot-value-use.ts create mode 100644 tests/diagnostics/unmappable-declaration-initializer.ts create mode 100644 tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt create mode 100644 tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 21c0b00f8..e63ab750c 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -1023,6 +1023,33 @@ export function noLoweringDiag( }; } +/** A stdlib-global snapshot reached a site that needs its VALUE (an + * argument, a return, a stored field). The declaration emitted no storage + * — alias plumbing only serves member and call reads — and the global's + * own value has no lowering, so the read cannot resolve. Reported at the + * declaration, where the real cause sits; the use site carries the SC2004 + * cascade. */ +export function stdlibSnapshotValueUseDiag( + name: string, + alias: string, + typeText: string | undefined, + loc: SrcLoc, +): ScrDiagnostic { + // Name the requested type when it has a readable spelling; a structural + // dump of the global's own object type says less than the global itself. + const unholdable = + typeText === undefined + ? "the global it names is not a value scriptc can hold" + : `a '${typeText}' is not a value scriptc can hold`; + return { + code: "SC2020", + message: `the stdlib-global snapshot '${name}' ('${alias}') cannot be used as a value: the declaration lowers to alias plumbing (no storage, no code), and ${unholdable}`, + loc, + milestone: "later", + hint: `read the members through the global directly ('${alias}.member') instead of passing '${name}' around as a value`, + }; +} + /** A REACHED use of a part of the WebAssembly API scriptc does not * implement yet (SC2020): the streaming functions, Tag and Exception, the * classes as values. compile, instantiate, validate, the Module, Instance, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a16d2f396..df4498846 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -286,6 +286,7 @@ import { noLoweringDiag, overloadedSignatureTypeDiag, recordShapeMismatchDiag, + stdlibSnapshotValueUseDiag, unsupportedNativeApiDiag, unsupportedPackageValueDiag, unionMismatchDiag, @@ -3378,6 +3379,16 @@ export class Lowerer { * checks resolve through this map (stdlibGlobalNameOf), declarations * emit nothing. */ readonly stdlibGlobalAliases = new Map(); + /** The TypeScript declarations registered in stdlibGlobalAliases: the + * global each one names and its own name node. Their declarations emit + * nothing, so a read that needs the VALUE reports at the declaration + * (stdlibGlobalAliasDecl, rejectUnresolvedSymbol) instead of compiling + * it away. An unread snapshot stays elided: reading a global has no + * side effects. */ + readonly stdlibGlobalAliasDecls = new Map(); + /** Snapshots whose declaration-side refusal already reported (one + * declaration, one report). */ + private readonly stdlibSnapshotReported = new Set(); /** The stdlibGlobalAliases bound through a cast of the global object * (`(globalThis as { Buffer?: B }).Buffer`). Their static type is the * cast's, so their value reads are refused (identifiers.ts). */ @@ -4222,6 +4233,10 @@ export class Lowerer { checkedTable("methodLambdasInProgress", () => this.methodLambdasInProgress.size), checkedTable("classMethodValueSelections", () => this.classMethodValueSelections.size), checkedTable("stdlibGlobalAliases", () => this.stdlibGlobalAliases.size), + // The snapshots' own declarations and their one-report memory: both + // grow with stdlibGlobalAliases, one entry per registered declaration. + checkedTable("stdlibGlobalAliasDecls", () => this.stdlibGlobalAliasDecls.size), + checkedTable("stdlibSnapshotReported", () => this.stdlibSnapshotReported.size), checkedTable("cjsAccessorFns", () => this.cjsAccessorFns.size), checkedTable("cjsAccessorSnapshots", () => this.cjsAccessorSnapshots.size), checkedTable("cjsSnapshotReady", () => this.cjsSnapshotReady.size), @@ -11358,6 +11373,24 @@ export class Lowerer { throw new PoisonError(); } + /** The declaration-side refusal of a stdlib-global snapshot read as a + * VALUE (stdlibGlobalAliasDecl): report once, at the declaration's name, + * naming the type the program asked the compiler to hold when it has a + * short spelling. */ + reportStdlibSnapshot(symbol: ts.Symbol): void { + const decl = this.stdlibGlobalAliasDecls.get(symbol); + if (decl === undefined || this.stdlibSnapshotReported.has(symbol)) return; + this.stdlibSnapshotReported.add(symbol); + const raw = panicSafe( + () => this.checker.typeToString(this.checker.getTypeOfSymbol(symbol)), + "", + ); + const typeText = raw !== "" && raw.length <= 48 ? raw : undefined; + this.pushDiag( + stdlibSnapshotValueUseDiag(decl.name.text, decl.alias, typeText, locOf(decl.name)), + ); + } + stdlibMemberFence(access: ts.PropertyAccessExpression): void { return stdlibMemberFence(this, access); } @@ -15508,6 +15541,18 @@ export class Lowerer { this.pushDiag(blockedBindingUseDiag(name, locOf(node))); throw new PoisonError(); } + // A stdlib-global snapshot read as a VALUE (`return proc` for + // `const proc = globalThis.process`): the declaration emitted no + // storage — alias plumbing only serves member and call reads — and the + // global's own value has no lowering. The cause is the declaration, so + // report there (once) and cascade here, instead of the generic + // binding-form text that names neither. + if (symbol !== null && this.stdlibGlobalAliasDecls.has(symbol)) { + this.reportStdlibSnapshot(symbol); + this.blockedBindings.add(symbol); + this.pushDiag(blockedBindingUseDiag(name, locOf(node))); + throw new PoisonError(); + } // A binding whose TYPE keeps a generic call signature and whose // declaration carries no initializer (`declare const o4: undefined | // ((f: (a: T) => T) => T)` — the optional-chained ambient shape): diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..ded666ab2 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -3717,6 +3717,15 @@ export function stdlibGlobalAliasDecl( lowerer.stdlibGlobalAliases.set(symbol, name); if (init !== undefined && castGlobalAliasSource(lowerer, init)) lowerer.castGlobalAliases.add(symbol); + // The snapshot exists for reads, and its declaration emits nothing: a + // read that needs its VALUE (an argument, a return) cannot resolve, so + // register the declaration to report the cause there rather than at the + // use (rejectUnresolvedSymbol). TypeScript sources only: JavaScript + // bindings keep their checked-dynamic value paths. A snapshot no read + // resolves stays elided — reading a global has no side effects, so + // eliding an unread one is observationally identical to Node. + if (!isJsSourceFile(ts.getSourceFileOfNode(nameNode))) + lowerer.stdlibGlobalAliasDecls.set(symbol, { alias: name, name: nameNode }); return true; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json new file mode 100644 index 000000000..4cf28c2c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stdlib-snapshot-value-use.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json new file mode 100644 index 000000000..dc30afdbc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/unmappable-declaration-initializer.ts" + ], + "diags": [] +} diff --git a/tests/corpus/990-process-basics.ts b/tests/corpus/990-process-basics.ts index c09e19b74..c902d0c78 100644 --- a/tests/corpus/990-process-basics.ts +++ b/tests/corpus/990-process-basics.ts @@ -31,3 +31,10 @@ console.log(nonEmpty === process.argv.length); const cwd = process.cwd(); console.log(cwd); console.log(cwd === process.cwd(), cwd.startsWith("/")); + +// A stdlib-global snapshot (`const proc = globalThis.process`) lowers to +// alias plumbing — no storage, no code — and reads through it resolve to +// the global itself. +const proc = globalThis.process; +console.log(proc.pid === process.pid, proc.platform === process.platform); +console.log(proc.argv === process.argv); diff --git a/tests/diagnostics/stdlib-snapshot-value-use.ts b/tests/diagnostics/stdlib-snapshot-value-use.ts new file mode 100644 index 000000000..563dd88d8 --- /dev/null +++ b/tests/diagnostics/stdlib-snapshot-value-use.ts @@ -0,0 +1,10 @@ +// The snapshot's declaration emits no storage: only reads that resolve +// THROUGH the global (members, calls) can exist. A read that needs the +// value reports the declaration's cause — the binding, the global it +// snapshots and why neither holds a value — and the use site carries the +// inherited-blocker cascade instead of "a binding form with no lowering". +function label(): unknown { + const proc = globalThis.process; + return proc; +} +console.log(label()); diff --git a/tests/diagnostics/unmappable-declaration-initializer.ts b/tests/diagnostics/unmappable-declaration-initializer.ts new file mode 100644 index 000000000..823f430e6 --- /dev/null +++ b/tests/diagnostics/unmappable-declaration-initializer.ts @@ -0,0 +1,10 @@ +// An initializer is never dropped. This declaration's type has no lowering, +// so the declaration is refused where it is written — it cannot compile away +// with the call in its initializer, which would run under Node. +function touch(): number { + return 1; +} +function run(): void { + const ref: WeakRef = touch() as unknown as WeakRef; +} +run(); diff --git a/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt b/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt new file mode 100644 index 000000000..f4dce44f0 --- /dev/null +++ b/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt @@ -0,0 +1,17 @@ +stdlib-snapshot-value-use.ts:7:9 - error SC2020: the stdlib-global snapshot 'proc' ('process') cannot be used as a value: the declaration lowers to alias plumbing (no storage, no code), and the global it names is not a value scriptc can hold + + 6 | function label(): unknown { + 7 | const proc = globalThis.process; + | ^~~~ + 8 | return proc; + + hint: read the members through the global directly ('process.member') instead of passing 'proc' around as a value + +stdlib-snapshot-value-use.ts:8:10 - error SC2004: uses of 'proc' inherit the blocker on its declaration + + 7 | const proc = globalThis.process; + 8 | return proc; + | ^~~~ + 9 | } + + hint: the declaration of 'proc' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt b/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt new file mode 100644 index 000000000..c4dc441ca --- /dev/null +++ b/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt @@ -0,0 +1,8 @@ +unmappable-declaration-initializer.ts:8:9 - error SC2020: 'WeakRef' is part of the standard library types but has no scriptc lowering yet + + 7 | function run(): void { + 8 | const ref: WeakRef = touch() as unknown as WeakRef; + | ^~~ + 9 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file From d10d5578af594b3dce7d6085140bf4824044e66f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 20:37:15 -0500 Subject: [PATCH 26/47] Put program constructors in .init_array on ELF The helper's program target options left UseInitArray off, so a program constructor went to .ctors. The C startup files and lld run only .init_array entries, so such a constructor never ran. Runtime units already set the option (runtime-unit.cpp); program code now does too. No program emits a constructor today, so executables are unchanged. native-constructors.test.ts emits a constructor through a single object, split code generation and program partitions, checks that it lands in .init_array on ELF, and runs it. --- native/llvm-codegen/src/target.cpp | 3 + .../compiler/test/native-constructors.test.ts | 145 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/compiler/test/native-constructors.test.ts diff --git a/native/llvm-codegen/src/target.cpp b/native/llvm-codegen/src/target.cpp index 2695d478f..a4c442935 100644 --- a/native/llvm-codegen/src/target.cpp +++ b/native/llvm-codegen/src/target.cpp @@ -127,6 +127,9 @@ TargetOptions targetOptions(const Triple &TargetTriple) { // The Android API 26 runtime pack uses the NDK's emulated TLS ABI. // Program globals and runtime thread instances must use the same ABI. Options.EmulatedTLS = TargetTriple.isAndroid(); + // Program constructors go in .init_array, which the C runtime's startup + // files run on ELF, as runtime units' constructors do (runtime-unit.cpp). + Options.UseInitArray = TargetTriple.isOSBinFormatELF(); if (ProfileLayout && TargetTriple.isOSBinFormatELF()) { Options.FunctionSections = true; Options.EnableMachineFunctionSplitter = true; diff --git a/packages/compiler/test/native-constructors.test.ts b/packages/compiler/test/native-constructors.test.ts new file mode 100644 index 000000000..1aada9b0e --- /dev/null +++ b/packages/compiler/test/native-constructors.test.ts @@ -0,0 +1,145 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { nativeCodegenTarget, nativeHelperForTarget } from "../src/backend/targets.js"; + +const execFileAsync = promisify(execFile); +const target = nativeCodegenTarget(); +const helperSpec = target === null ? null : nativeHelperForTarget(target); +const helper = (() => { + if (helperSpec === null || process.platform === "win32") return null; + try { + const packageJson = createRequire(import.meta.url).resolve( + `${helperSpec.packageName}/package.json`, + ); + return join(dirname(packageJson), "bin", "scriptc-llvm-codegen"); + } catch { + return null; + } +})(); +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** A module whose constructor sets the value main returns. Functions after + * the constructor give split code generation and partitions something to + * divide. */ +function program(): string { + const lines = [ + "@value = internal global i32 0", + "@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] [{ i32, ptr, ptr } { i32 65535, ptr @init, ptr null }]", + "define internal void @init() {", + // Volatile, so the optimizer cannot evaluate the constructor at compile time. + " store volatile i32 42, ptr @value", + " ret void", + "}", + ]; + for (let index = 0; index < 8; index++) + lines.push( + `define internal i32 @step${index}(i32 %v) noinline {`, + ` %r = add i32 %v, ${index}`, + " ret i32 %r", + "}", + ); + lines.push( + "define i32 @main() {", + " %value = load i32, ptr @value", + ...Array.from({ length: 8 }, (_, index) => ` %s${index} = call i32 @step${index}(i32 0)`), + " ret i32 %value", + "}", + ); + return lines.join("\n") + "\n"; +} + +/** The objects' section names, on ELF targets (null elsewhere). */ +async function sectionNames(objects: string[]): Promise { + if (process.platform !== "linux") return null; + const names: string[] = []; + for (const object of objects) { + const { stdout } = await execFileAsync("readelf", ["-S", "-W", object]); + for (const match of stdout.matchAll(/^\s*\[\s*\d+\]\s+(\S+)/gm)) names.push(match[1]!); + } + return names; +} + +/** Links with lld when the C compiler driver can use it, as scriptc's Linux + * links do: unlike the BFD linker's default script, lld does not run .ctors + * entries from .init_array. */ +async function link(objects: string[], executable: string): Promise { + const cc = process.env["CC"] ?? "cc"; + try { + await execFileAsync(cc, ["-fuse-ld=lld", ...objects, "-o", executable]); + } catch { + await execFileAsync(cc, [...objects, "-o", executable]); + } +} + +async function exitCode(options: { + outputs: number; + split?: boolean; + name: string; +}): Promise<{ code: number; sections: string[] | null }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-constructors-")); + dirs.push(dir); + const input = join(dir, `${options.name}.ll`); + await writeFile(input, program()); + const outputs = Array.from({ length: options.outputs }, (_, index) => + join(dir, `${options.name}.${index}.o`), + ); + await execFileAsync(helper!, [ + "emit", + "--input", + input, + ...outputs.flatMap((output) => ["--output", output]), + "--filetype", + "obj", + "--target", + target!.llvmTriple, + "--opt-level", + "2", + "--relocation-model", + "pic", + "--diagnostic-format", + "json", + "--source-path", + "/src/program.ts", + ...(options.split === true ? ["--split", "codegen"] : []), + ]); + const sections = await sectionNames(outputs); + const executable = join(dir, options.name); + await link(outputs, executable); + try { + await execFileAsync(executable, []); + return { code: 0, sections }; + } catch (error) { + return { code: (error as { code: number }).code, sections }; + } +} + +/** The constructor ran, and on ELF it is in .init_array, never .ctors. */ +function expectConstructorRan(result: { code: number; sections: string[] | null }): void { + expect(result.code).toBe(42); + if (result.sections === null) return; + expect(result.sections).toContain(".init_array"); + expect(result.sections).not.toContain(".ctors"); +} + +describe.runIf(helper !== null)("program constructors", () => { + test("a constructor runs before main in a single object", async () => { + expectConstructorRan(await exitCode({ outputs: 1, name: "single" })); + }); + + test("a constructor runs before main with split code generation", async () => { + expectConstructorRan(await exitCode({ outputs: 3, split: true, name: "split" })); + }); + + test("a constructor runs before main across program partitions", async () => { + expectConstructorRan(await exitCode({ outputs: 3, name: "partitions" })); + }); +}); From abb87f4dd73ef744c977a2973e944516ee1695b4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 21:13:01 -0500 Subject: [PATCH 27/47] Grow the ASCII run before walking for forward index reads A scanner reading a mostly ASCII text front to back (slices and positioned startsWith at increasing positions) walked from the hot cursor on every read within 64 units of it, since the near-cursor walk came before the ASCII-run growth. A forward read past the run now grows the run first. When a non-ASCII character ends the run, a nearby read walks from the run's end or the cursor and starts a new run where it lands. --- packages/runtime/src/scr_string.c | 36 +++++++++++++++++---- packages/runtime/test/test_string.c | 49 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index a9138cedc..d712ebb84 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -1756,6 +1756,36 @@ static __attribute__((noinline)) size_t scr_u16_to_byte_walk(const ScrStr *s, Sc e->cb = cb; return cb; } + /* A forward access past the cached ASCII run (a scanner taking slices at + * increasing positions of a mostly ASCII text) grows the run over the + * ASCII bytes up to it. When a non-ASCII character ends the run first, a + * nearby access walks from the run's end or the hot cursor, whichever is + * nearer, and a new run starts where the walk lands, so the accesses after + * it map by arithmetic again. */ + if (u16 >= e->ascii_cu) { + if (scr_sidx_ascii_grow(s, e, e->ascii_cb + (u16 - e->ascii_cu))) { + e->cb = e->ascii_cb + (u16 - e->ascii_cu); + e->cu = u16; + *mid = false; + return e->cb; + } + size_t run_cu = e->ascii_cu + (e->ascii_end - e->ascii_cb); + if (e->ascii_end < s->len && u16 - run_cu <= SCR_SIDX_NEAR_UNITS) { + size_t cu = run_cu, cb = e->ascii_end; + if (e->cb <= s->len && e->cu >= cu && e->cu <= u16) { + cu = e->cu; + cb = e->cb; + } + size_t at = scr_sidx_walk_from(s, e, cu, cb, u16, mid, false); + if (!*mid && at < s->len && (unsigned char)s->data[at] < 0x80) { + e->ascii_cu = e->cu; + e->ascii_cb = at; + e->ascii_end = at; + (void)scr_sidx_ascii_grow(s, e, at); + } + return at; + } + } /* Within a short distance of the hot cursor (a scanner * taking slices near its position), walk from that cursor: no index * search, extension or ASCII-run growth is cheaper. */ @@ -1785,12 +1815,6 @@ static __attribute__((noinline)) size_t scr_u16_to_byte_walk(const ScrStr *s, Sc return cb; } } - if (u16 >= e->ascii_cu && scr_sidx_ascii_grow(s, e, e->ascii_cb + (u16 - e->ascii_cu))) { - e->cb = e->ascii_cb + (u16 - e->ascii_cu); - e->cu = u16; - *mid = false; - return e->cb; - } scr_sidx_extend_to_u16(s, e, u16); /* Extending a far-end lookup can just have proved identity. Do not * materialize an index that the identity fast path will never consult. */ diff --git a/packages/runtime/test/test_string.c b/packages/runtime/test/test_string.c index 5a3bdfe5f..3bce02761 100644 --- a/packages/runtime/test/test_string.c +++ b/packages/runtime/test/test_string.c @@ -927,6 +927,54 @@ static void local_navigation_asserts(void) { scr_sidx_test_reset_cache(); } +/* A scanner reading a mostly ASCII text front to back (positioned + * startsWith and substrings at increasing positions, with a non-ASCII + * character every 300 bytes) maps its positions through the ASCII run, so + * it walks only across the non-ASCII characters. */ +static void forward_scan_asserts(void) { + enum { ASCII = 300, UNITS = ASCII + 1, REPS = 100 }; + char piece[ASCII + 2]; + for (size_t i = 0; i < ASCII; i++) piece[i] = (char)('a' + i % 26); + memcpy(piece + ASCII, "\xC3\xA9", 2); /* é: one unit */ + ScrStr *unit = scr_str_new(piece, sizeof piece); + ScrStr *s = scr_str_repeat(unit, REPS); + scr_str_release(unit); + scr_sidx_test_reset_cache(); + if (scr_str_utf16_len(s) != UNITS * REPS) sidx_fail("forward scan length"); + ScrStr *needle = scr_str_new("ab", 2); + scr_sidx_test_reset_steps(); + for (size_t at = 0; at + 2 <= UNITS * REPS; at += 5) { + size_t offset = at % UNITS; + bool expected = offset + 1 < ASCII && offset % 26 == 0; + if (scr_str_starts_with_from(s, needle, (double)at) != expected) { + sidx_fail("forward startsWith"); + break; + } + char want[4]; + size_t n = 0; + for (size_t k = at; k < at + 2; k++) { + size_t o = k % UNITS; + if (o < ASCII) want[n++] = (char)('a' + o % 26); + else { + want[n++] = '\xC3'; + want[n++] = '\xA9'; + } + } + ScrStr *part = scr_str_substring(s, (double)at, (double)(at + 2)); + bool same = part->len == n && memcmp(part->data, want, n) == 0; + scr_str_release(part); + if (!same) { + sidx_fail("forward substring"); + break; + } + } + if (scr_sidx_test_walk_steps() > REPS * 16) + sidx_fail("forward reads walked between non-ASCII characters"); + scr_str_release(needle); + scr_str_release(s); + scr_sidx_test_reset_cache(); +} + /* An entry borrowed for a receiver must remain its entry when a second * string needs indexing. Fill the cursor tier so the needle would evict * precisely the receiver under a receiver-first lookup order. */ @@ -1244,6 +1292,7 @@ int main(int argc, char **argv) { index_activity_asserts(); sparse_slice_churn_asserts(); local_navigation_asserts(); + forward_scan_asserts(); positioned_suffix_eviction_asserts(); #endif From 52e3dadc20eb658ce84b5e6c7221f60b6cb3c3c2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 21:16:57 -0500 Subject: [PATCH 28/47] Match Node for BigIntStats dates, bigint option effects and path namespace spreads - A BigIntStats Date member is built from its truncated millisecond member, as Node's getters do, where the Stats getters round the fractional one: a timestamp at least 0.5 ms past a millisecond no longer reads one millisecond later than Node. - statSync, lstatSync and fstatSync evaluate a bigint option whose value can have an effect (`bigint: (count++, true)`), in member order after the path and before the stat; an exception it throws is the call's. - An object spread of a node:path namespace in JavaScript copies the module's members, read through the module value so the copy holds the same functions: an ES module namespace with its keys sorted and `default`, and posix, win32 and the default import in Node's member order. - scr_lib.h declares the BigIntStats readers, so every runtime name the backend can emit has a prototype in scr_runtime.h. --- .../compiler/src/backend/llvm/lib-shared.ts | 8 +- .../frontend/lowering/builtins/filesystem.ts | 78 ++++++++++++++++--- .../lowering/expressions/object-literals.ts | 18 ++++- .../frontend/lowering/lower-builtin-values.ts | 73 +++++++++++++++++ .../corpus/path-namespace-spreads.js.json | 6 ++ packages/runtime/src/scr_lib.h | 26 +++++++ packages/runtime/src/scr_stats_bigint.c | 19 ++++- tests/corpus/7018-bigint-stats.ts | 43 ++++++++++ tests/corpus/path-namespace-spreads.js | 23 ++++++ .../corpus-costs/7018-bigint-stats.ts.json | 2 +- .../path-namespace-spreads.js.json | 1 + 11 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json create mode 100644 tests/corpus/path-namespace-spreads.js create mode 100644 tests/harness/corpus-costs/path-namespace-spreads.js.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 31ed383c1..13d0cfed4 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -645,10 +645,10 @@ export const LIB_FN_SYMS: Record = { "bigstats.mtimeNs": "scr_stats_big_mtime_ns", "bigstats.ctimeNs": "scr_stats_big_ctime_ns", "bigstats.birthtimeNs": "scr_stats_big_birthtime_ns", - "bigstats.atime": "scr_stats_atime", - "bigstats.mtime": "scr_stats_mtime", - "bigstats.ctime": "scr_stats_ctime", - "bigstats.birthtime": "scr_stats_birthtime", + "bigstats.atime": "scr_stats_big_atime", + "bigstats.mtime": "scr_stats_big_mtime", + "bigstats.ctime": "scr_stats_big_ctime", + "bigstats.birthtime": "scr_stats_big_birthtime", "text.decode": "scr_text_decode", "bytes.bufferSource": "scr_bytes_buffer_source", "bytes.copyBrand": "scr_bytes_copy_brand", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index 21c505fab..7a2b262da 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -1527,13 +1527,15 @@ function statRepresentation( /** The `bigint` option member. The literal picks the representation — * BigIntStats for true, Stats for false — and the call's own type must * already agree with it, because a `Stats | BigIntStats`-valued flag has no - * static member table. */ + * static member table. Answers the member's lowered value when evaluating + * it can have an effect (`bigint: (count++, true)`), which the call then + * runs in its place among the options; null when it can be dropped. */ function checkStatBigintOption( lowerer: Lowerer, value: ts.Expression, spelling: string, representation: "bigint" | "number" | null, -): void { +): IrExpr | null { const t = lowerer.typeOf(value); const literal = t.flags & ts.TypeFlags.BooleanLiteral ? lowerer.checker.typeToString(t) : null; if (literal === null) { @@ -1550,6 +1552,8 @@ function checkStatBigintOption( "pass bigint: true to receive BigIntStats, or omit it to receive Stats", ); } + const lowered = lowerer.lowerExprExpecting(value, BOOL); + return isSafeToDiscard(lowered) ? null : lowered; } /** fs.statSync / fs.lstatSync(path, options). The options are an object @@ -1658,6 +1662,10 @@ export function lowerFsStatOptionsCall( lowerer.noLowering(`${spelling} with this options value`, optsNode, "pass an object literal"); } } + // The members in source order, when a bigint member has an effect: the + // throwIfNoEntry value ("throwIfNoEntry") and each effect. + const members: (IrExpr | "throwIfNoEntry")[] = []; + let bigintEffect = false; for (const p of ts.isObjectLiteralExpression(optsNode) ? optsNode.properties.nodes : []) { const m = optionMember(p); if (!m) { @@ -1669,8 +1677,13 @@ export function lowerFsStatOptionsCall( } if (m.name === "throwIfNoEntry") { throwIfNoEntry = lowerer.lowerExprExpecting(m.value, BOOL); + members.push("throwIfNoEntry"); } else if (m.name === "bigint") { - checkStatBigintOption(lowerer, m.value, spelling, representation); + const effect = checkStatBigintOption(lowerer, m.value, spelling, representation); + if (effect !== null) { + members.push(effect); + bigintEffect = true; + } } else { fenceOrDropOptionKey( lowerer, @@ -1682,6 +1695,26 @@ export function lowerFsStatOptionsCall( ); } } + // With a bigint member's effect, the path binds first, then the members + // run in source order (a runtime throwIfNoEntry into a local), then the + // stat; without one, the call evaluates its operands in that order itself. + const ordered = (stat: (pathArg: IrExpr, flag: IrExpr | null) => IrExpr): IrExpr => { + if (!bigintEffect) return stat(path, throwIfNoEntry); + const pathLocal = lowerer.declareHiddenLocal("%statPath", STRING); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: pathLocal.id, init: path, loc: path.loc }]; + let flag = throwIfNoEntry; + for (const member of members) { + if (member !== "throwIfNoEntry") { + stmts.push({ kind: "exprStmt", expr: member, loc: member.loc }); + } else if (flag !== null && flag.kind !== "boolLit") { + const flagLocal = lowerer.declareHiddenLocal("%statThrowIfNoEntry", BOOL); + stmts.push({ kind: "varDecl", localId: flagLocal.id, init: flag, loc: flag.loc }); + flag = varRef(flagLocal.id, BOOL, flag.loc); + } + } + const result = stat(varRef(pathLocal.id, STRING, loc), flag); + return { kind: "seqExpr", stmts, result, type: result.type, loc }; + }; if (callType?.kind === "stats" || callType?.kind === "bigintStats") { // A runtime flag that is false answers undefined, which a Stats result // cannot hold. @@ -1692,7 +1725,14 @@ export function lowerFsStatOptionsCall( "the result must be Stats | undefined when throwIfNoEntry may be false", ); } - return plainAfter(null); + if (!bigintEffect) return plainAfter(null); + return ordered((pathArg) => ({ + kind: "libCall", + fn: plainFn, + args: [pathArg], + type: statsType, + loc, + })); } if ( !def || @@ -1706,13 +1746,13 @@ export function lowerFsStatOptionsCall( `${spelling}(path, { throwIfNoEntry: false }) answers Stats | undefined`, ); } - return { + return ordered((pathArg, flag) => ({ kind: "libCall", fn: noFollow ? "fs.lstatProbeSync" : "fs.statProbeSync", - args: [path, throwIfNoEntry ?? boolLit(true, loc)], + args: [pathArg, flag ?? boolLit(true, loc)], type: callType!, loc, - }; + })); } /** fs.fstatSync(fd, options): the descriptor form of the same snapshot. @@ -1765,6 +1805,7 @@ export function lowerFsStatFdOptionsCall( `pass an object literal: ${spelling}(fd, { bigint: true })`, ); } + const effects: IrExpr[] = []; for (const p of optsNode.properties.nodes) { const m = optionMember(p); if (!m) { @@ -1775,7 +1816,8 @@ export function lowerFsStatFdOptionsCall( ); } if (m.name === "bigint") { - checkStatBigintOption(lowerer, m.value, spelling, representation); + const effect = checkStatBigintOption(lowerer, m.value, spelling, representation); + if (effect !== null) effects.push(effect); } else { fenceOrDropOptionKey( lowerer, @@ -1787,7 +1829,25 @@ export function lowerFsStatFdOptionsCall( ); } } - return plain(); + if (effects.length === 0) return plain(); + // The descriptor binds first, then the bigint member's effect, then the stat. + const fdLocal = lowerer.declareHiddenLocal("%fstatFd", F64); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: fdLocal.id, init: fd, loc: fd.loc }, + ...effects.map((expr): IrStmt => ({ kind: "exprStmt", expr, loc: expr.loc })), + ], + result: { + kind: "libCall", + fn: "fs.fstatSync", + args: [varRef(fdLocal.id, F64, loc)], + type: statsType, + loc, + }, + type: statsType, + loc, + }; } /** `d.isFile()` / `d.isDirectory()` / `d.isSymbolicLink()` on a Dirent- diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 492736883..a3d2a5b11 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 { pathModuleSpreadSource } from "../lower-builtin-values.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; import { @@ -310,7 +310,12 @@ 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)) + ? pathModuleSpreadSource( + lowerer, + builtin === "path" ? "path/posix" : builtin, + isNamespaceImport(lowerer, prop.expression), + locOf(prop), + ) : !boxValue && conditionalArms && (conditionalSpreadOf(prop.expression) !== null || @@ -3675,3 +3680,12 @@ function rejectThisInObjectAccessor(lowerer: Lowerer, node: ts.Node): void { } ts.forEachChild(node, (child) => rejectThisInObjectAccessor(lowerer, child)); } + +/** Whether a spread source is an ES module namespace binding + * (`import * as path`); a default import, a require and a named + * sub-namespace are module objects. */ +function isNamespaceImport(lowerer: Lowerer, expr: ts.Expression): boolean { + if (!ts.isIdentifier(expr)) return false; + const decl = lowerer.checker.getSymbolAtLocation(expr)?.declarations[0]; + return decl !== undefined && ts.isNamespaceImport(decl); +} diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index 3b4180c95..1058b1c26 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -435,6 +435,79 @@ export function pathModuleValue(lowerer: Lowerer, module: string, loc: SrcLoc): return moduleValue(lowerer, module, loc); } +/** Node's own keys of the node:path module objects (path, path.posix and + * path.win32), in order; `_makeLong` is the same function as + * toNamespacedPath. */ +const PATH_MODULE_KEYS = [ + "resolve", + "normalize", + "isAbsolute", + "join", + "relative", + "toNamespacedPath", + "dirname", + "basename", + "extname", + "format", + "parse", + "matchesGlob", + "sep", + "delimiter", + "win32", + "posix", + "_makeLong", +] as const; + +/** What a spread of a node:path namespace copies: the module object's own + * members in Node's order, each read through the module value, so the copy + * holds the same functions (`{ ...path }.join === path.join`). An ES module + * namespace (`import * as path`) has its keys sorted and also `default`, + * the module object. Members the module value does not answer (format and + * parse have no function values) are left out, as they are of the value. */ +export function pathModuleSpreadSource( + lowerer: Lowerer, + module: string, + namespace: boolean, + loc: SrcLoc, +): IrExpr { + const local = lowerer.declareHiddenLocal("%pathModule", DYN); + const value = varRef(local.id, DYN, loc); + const answered = (key: string): boolean => + key === "default" || + key === "posix" || + key === "win32" || + builtinModuleConstOf(lowerer, module, key) !== undefined || + lowerer.lowerBuiltinCallableValue( + { module, member: key === "_makeLong" ? "toNamespacedPath" : key }, + loc, + ) !== null; + const keys = (namespace ? [...PATH_MODULE_KEYS, "default"].sort() : [...PATH_MODULE_KEYS]).filter( + answered, + ); + const fields = keys.map((key) => ({ + key: strLit(key, loc), + value: + key === "default" + ? value + : ({ + kind: "dynKeyGet", + value, + key: strLit(key === "_makeLong" ? "toNamespacedPath" : key, loc), + type: DYN, + loc, + } as IrExpr), + })); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: local.id, init: pathModuleValue(lowerer, module, loc), loc }, + ], + result: { kind: "dynObjLit", fields, type: DYN, loc }, + type: DYN, + loc, + }; +} + export function utilTypesModuleValue(lowerer: Lowerer, loc: SrcLoc): IrExpr { return moduleValue(lowerer, "util/types", loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json new file mode 100644 index 000000000..07aec4c5c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/path-namespace-spreads.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..e6975d618 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -477,6 +477,32 @@ double scr_stats_atime(ScrStats *s); double scr_stats_mtime(ScrStats *s); double scr_stats_ctime(ScrStats *s); double scr_stats_birthtime(ScrStats *s); +/* BigIntStats members (scr_stats_bigint.c): every number member of Stats + * as a bigint (+1), the `*Ns` nanosecond members, and the Date fields' + * time values from the truncated `*Ms` member. */ +typedef struct ScrBigInt ScrBigInt; +ScrBigInt *scr_stats_big_dev(ScrStats *s); +ScrBigInt *scr_stats_big_mode(ScrStats *s); +ScrBigInt *scr_stats_big_nlink(ScrStats *s); +ScrBigInt *scr_stats_big_uid(ScrStats *s); +ScrBigInt *scr_stats_big_gid(ScrStats *s); +ScrBigInt *scr_stats_big_rdev(ScrStats *s); +ScrBigInt *scr_stats_big_blksize(ScrStats *s); +ScrBigInt *scr_stats_big_ino(ScrStats *s); +ScrBigInt *scr_stats_big_size(ScrStats *s); +ScrBigInt *scr_stats_big_blocks(ScrStats *s); +ScrBigInt *scr_stats_big_atime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_mtime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_ctime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_birthtime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_atime_ms(ScrStats *s); +ScrBigInt *scr_stats_big_mtime_ms(ScrStats *s); +ScrBigInt *scr_stats_big_ctime_ms(ScrStats *s); +ScrBigInt *scr_stats_big_birthtime_ms(ScrStats *s); +double scr_stats_big_atime(ScrStats *s); +double scr_stats_big_mtime(ScrStats *s); +double scr_stats_big_ctime(ScrStats *s); +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); /* Node's Stats (or BigIntStats) object for the snapshot: own fields in the diff --git a/packages/runtime/src/scr_stats_bigint.c b/packages/runtime/src/scr_stats_bigint.c index d598ee148..f9bf38fdd 100644 --- a/packages/runtime/src/scr_stats_bigint.c +++ b/packages/runtime/src/scr_stats_bigint.c @@ -8,10 +8,13 @@ * integers, the `*Ms` millisecond members (the truncated nanosecond * quotient, as the binding computes them) and the BigIntStats-only `*Ns` * nanosecond members (sec * 1e9 + nsec, exactly as scr_lib.c's dynamic - * conversion does). The four time members are the base unit's Date reads. - * Every function here answers +1 like the rest of the snapshot accessors. */ + * conversion does). The four Date members are built from the `*Ms` member, + * as Node's getters do (new Date(Math.round(Number(this.atimeMs)))): the + * truncated millisecond, where Stats rounds its fractional one. + * Every bigint here is answered +1 like the rest of the snapshot accessors. */ #include "scr_bigint.h" #include "scr_lib.h" +#include "scr_number.h" #include "scr_stats_private.h" /* The binding hands its BigInt64Array signed values. */ @@ -82,3 +85,15 @@ ScrBigInt *scr_stats_big_ctime_ms(ScrStats *s) { ScrBigInt *scr_stats_big_birthtime_ms(ScrStats *s) { return scr_stats_big_ms_of(s, SCR_STATS_BIRTHTIME); } + +static double scr_stats_big_date_of(const ScrStats *s, int which) { + ScrBigInt *ms = scr_stats_big_ms_of(s, which); + double date = scr_date_new_ms(scr_bigint_to_f64(ms)); + scr_bigint_release(ms); + return date; +} + +double scr_stats_big_atime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_ATIME); } +double scr_stats_big_mtime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_MTIME); } +double scr_stats_big_ctime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_CTIME); } +double scr_stats_big_birthtime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_BIRTHTIME); } diff --git a/tests/corpus/7018-bigint-stats.ts b/tests/corpus/7018-bigint-stats.ts index 540fa03a9..2d69626b3 100644 --- a/tests/corpus/7018-bigint-stats.ts +++ b/tests/corpus/7018-bigint-stats.ts @@ -77,3 +77,46 @@ if (present !== undefined) console.log(present.size, typeof present.ino, present // The Date members agree with the nanosecond members. console.log(st.atime.getTime() === Number(st.atimeNs / 1000000n)); console.log(st.ctime instanceof Date, typeof st.ctimeMs, typeof st.ctimeNs); + +// A fractional millisecond of at least 0.5: Stats rounds its Date up to the +// next millisecond, while a BigIntStats Date is its truncated *Ms member. +const fractional = stamp + 0.0007; +fs.utimesSync("data.txt", fractional, fractional); +const rounded = fs.statSync("data.txt"); +const truncated = fs.statSync("data.txt", { bigint: true }); +console.log(rounded.mtime.getTime(), truncated.mtime.getTime(), truncated.mtimeMs); +console.log(rounded.atime.getTime() - truncated.atime.getTime()); +console.log( + truncated.atime.getTime() === Number(truncated.atimeMs), + truncated.mtime.getTime() === Number(truncated.mtimeMs), + truncated.ctime.getTime() === Number(truncated.ctimeMs), + truncated.birthtime.getTime() === Number(truncated.birthtimeMs), +); + +// The bigint option is evaluated like any option member: its effects run, +// in member order, before the stat, and an exception it throws is the call's. +let evaluated = ""; +function note(step: string): true { + evaluated += step; + return true; +} +function refuse(): true { + throw new Error("refused"); +} +const counted = fs.statSync("data.txt", { bigint: note("s") }); +const linked = fs.lstatSync("data.txt", { bigint: note("l") }); +const byFd = fs.openSync("data.txt", "r"); +const described = fs.fstatSync(byFd, { bigint: note("f") }); +fs.closeSync(byFd); +console.log(evaluated, typeof counted.size, typeof linked.size, typeof described.size); +evaluated = ""; +const probed = fs.statSync("missing.txt", { + bigint: note("b"), + throwIfNoEntry: ((evaluated += "t"), false), +}); +console.log(evaluated, probed === undefined); +try { + fs.statSync("data.txt", { bigint: refuse() }); +} catch (error) { + console.log((error as Error).message); +} diff --git a/tests/corpus/path-namespace-spreads.js b/tests/corpus/path-namespace-spreads.js new file mode 100644 index 000000000..c9931a451 --- /dev/null +++ b/tests/corpus/path-namespace-spreads.js @@ -0,0 +1,23 @@ +// 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. +import * as path from "node:path"; +import defaultPath, { posix, win32 } from "node:path"; + +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")); +console.log(keys(fromNamespace)); +console.log(keys(fromPosix)); +console.log(keys(fromDefault) === keys(fromPosix)); +console.log(keys(fromWin32) === keys(fromPosix)); +console.log(fromNamespace.join === path.join, fromNamespace.default === posix, fromPosix.basename === posix.basename); +console.log(fromWin32.join === win32.join, fromWin32.posix === posix, fromPosix.win32 === win32); +console.log(fromWin32._makeLong === win32.toNamespacedPath, fromDefault.sep, fromWin32.sep); +console.log(fromWin32.join("a", "b"), fromNamespace.normalize("/a//b/../c")); +const merged = { first: 0, ...win32, extra: 1 }; +console.log(Object.keys(merged)[0], merged.sep, merged.extra, keys(merged)); diff --git a/tests/harness/corpus-costs/7018-bigint-stats.ts.json b/tests/harness/corpus-costs/7018-bigint-stats.ts.json index d676e8919..3bf521f98 100644 --- a/tests/harness/corpus-costs/7018-bigint-stats.ts.json +++ b/tests/harness/corpus-costs/7018-bigint-stats.ts.json @@ -1 +1 @@ -{ "plain": 3400, "san": 25000 } +{ "plain": 4417, "san": 57812 } diff --git a/tests/harness/corpus-costs/path-namespace-spreads.js.json b/tests/harness/corpus-costs/path-namespace-spreads.js.json new file mode 100644 index 000000000..cd64ba8d0 --- /dev/null +++ b/tests/harness/corpus-costs/path-namespace-spreads.js.json @@ -0,0 +1 @@ +{ "plain": 4411, "san": 10661 } From cd04e38cec99f698d3a7203d3c171aa5cbc54c68 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 21:38:18 -0500 Subject: [PATCH 29/47] Mark the borrowed array read fallback as read-only A borrowed element read of a reference array falls back to scr_arr_borrow_ref when the element is outside the dense fast path. The call carried no memory attributes, so LLVM assumed it could write any memory: a loop holding such a read reloaded the array's header on every iteration, after inlining too. The fallback only reads the array (a hole or an out-of-range index traps), so it now carries the attributes of the other array read fallbacks, and the header loads stay hoisted. --- packages/compiler/src/backend/llvm/local-array-reads.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 8e9b1434d..f2cc49cb0 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -682,7 +682,11 @@ export function emitBorrowedArrayRead( B.startBlock(slow); host.declare("declare ptr @scr_arr_borrow_ref(ptr, double)"); const value = B.tmp(); - B.line(`${value} = call ptr @scr_arr_borrow_ref(ptr ${array.name}, double ${index.name})`); + // The fallback only reads the array (a hole or an out-of-range index + // traps), so loops keep the array's header loads hoisted past it. + B.line( + `${value} = call ptr @scr_arr_borrow_ref(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, + ); B.line(`store ptr ${value}, ptr ${slot}`); B.br(join); B.startBlock(join); From e506d19cd6128a98e6d4f114a907a2e1227d60ef Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 21:50:00 -0500 Subject: [PATCH 30/47] Name destructured builtins, parenthesized functions and util.debuglog as Node does Reading the name of a function stored in a const followed const aliases to the definition, but stopped at destructuring: `const { cwd } = process` gave the binding's name instead of Node's "wrappedCwd". A member destructured from the process global or a builtin module's namespace now gives the module table's name for it. An anonymous function wrapped in parentheses (`const f = (function () {})`) answered an empty name. As in Node, it takes the name of the binding or property it initializes through the parentheses. A builtin read as a property (`util.debuglog.name`) answered the name its shared adapter was first created under, so after `util.debug` it answered "debug". The name now comes from the property and the module table, and util.debug's value is named "debuglog", as in Node. --- .../lowering/expressions/property-access.ts | 16 +++--- .../compiler/src/frontend/lowering/lowerer.ts | 53 +++++++++++++++---- .../src/frontend/lowering/surfaces.ts | 2 + .../corpus/function-name-const-alias.mjs.json | 6 +++ tests/corpus/function-name-const-alias.mjs | 20 +++++++ .../function-name-const-alias.mjs.json | 1 + 6 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json create mode 100644 tests/corpus/function-name-const-alias.mjs create mode 100644 tests/harness/corpus-costs/function-name-const-alias.mjs.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index ea08cec65..bf954dc13 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1209,15 +1209,15 @@ export function lowerPropertyAccessExpr( (id) => lowerer.unions.get(id), ) ) { - // A builtin function value keeps Node's name, and its length when that - // is not its adapter's parameter count (`Math.max.length` is 2). Any - // other function is named by its definition, through const aliases. - const identity = lowerer.builtinFunctionIdentity(probed); + // A function is named by its definition, through const aliases: a + // builtin module member whose Node name differs from the member + // (`process.cwd` is `wrappedCwd`) by the module table, anything else + // by the property or function it reads. A builtin function value + // keeps its length when that is not its adapter's parameter count + // (`Math.max.length` is 2). const fnName = - identity.fnName ?? - builtinValueNameOf(expr.expression, lowerer) ?? - jsFuncNameOf(expr.expression, lowerer); - const fnLength = identity.fnLength; + builtinValueNameOf(expr.expression, lowerer) ?? jsFuncNameOf(expr.expression, lowerer); + const fnLength = lowerer.builtinFunctionIdentity(probed).fnLength; const boxed: IrExpr = { kind: "dynFrom", value: probed, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index f916708e2..9e544433e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2118,24 +2118,51 @@ export function builtinValueNameOf(node: ts.Node, lowerer: Lowerer): string | nu return builtinModuleFnOf(lowerer, imported.module, imported.member)?.valueName ?? null; const symbol = lowerer.resolveValueSymbol(n); const declaration = symbol?.valueDeclaration; + if (!symbol || seen.has(symbol) || !declaration) return null; + seen.add(symbol); + // A member destructured from a module or the process global + // (`const { cwd } = process`). + if (ts.isBindingElement(declaration)) { + const pattern = declaration.parent; + const holder = pattern?.parent; + const key = declaration.propertyName ?? declaration.name; + if ( + declaration.dotDotDotToken || + !pattern || + !ts.isObjectBindingPattern(pattern) || + !holder || + !ts.isVariableDeclaration(holder) || + !holder.initializer || + (ts.getCombinedNodeFlags(holder) & ts.NodeFlags.Const) === 0 || + !ts.isIdentifier(key) + ) + return null; + return builtinMemberValueName(lowerer, holder.initializer, key.text); + } if ( - !symbol || - seen.has(symbol) || - !declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer || (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 ) return null; - seen.add(symbol); n = declaration.initializer; } if (!ts.isPropertyAccessExpression(n)) return null; - const module = lowerer.isStdlibGlobal(n.expression, "process") + return builtinMemberValueName(lowerer, n.expression, n.name.text); +} + +/** BuiltinModuleFn.valueName of `member` read from `receiver`, the process + * global or a builtin module's namespace; null for anything else. */ +function builtinMemberValueName( + lowerer: Lowerer, + receiver: ts.Expression, + member: string, +): string | null { + const module = lowerer.isStdlibGlobal(receiver, "process") ? "process" - : lowerer.builtinNamespaceModuleOf(n.expression); + : lowerer.builtinNamespaceModuleOf(receiver); if (module === null) return null; - return builtinModuleFnOf(lowerer, module, n.name.text)?.valueName ?? null; + return builtinModuleFnOf(lowerer, module, member)?.valueName ?? null; } export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { @@ -2165,14 +2192,18 @@ export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { if (ts.isPropertyAccessExpression(n)) return n.name.text; if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) && n.name) return n.name.text; if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) { - const p = n.parent; - if (p && ts.isVariableDeclaration(p) && p.initializer === n && ts.isIdentifier(p.name)) { + // An anonymous function takes the name of the binding or property it + // initializes, through parentheses (`const f = (function () {})`). + let outer: ts.Node = n; + while (outer.parent && ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const p = outer.parent; + if (p && ts.isVariableDeclaration(p) && p.initializer === outer && ts.isIdentifier(p.name)) { return p.name.text; } - if (p && ts.isBindingElement(p) && p.initializer === n && ts.isIdentifier(p.name)) { + if (p && ts.isBindingElement(p) && p.initializer === outer && ts.isIdentifier(p.name)) { return p.name.text; } - if (p && ts.isPropertyAssignment(p) && p.initializer === n && ts.isIdentifier(p.name)) { + if (p && ts.isPropertyAssignment(p) && p.initializer === outer && ts.isIdentifier(p.name)) { return p.name.text; } } diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index cef6f91bd..dff1d1008 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1897,6 +1897,7 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { { mode: "optional", type: DYN, defaultValue: undefined }, ], }, + // util.debug is util.debuglog, under its name. debug: { fn: "util.debuglog", params: [DYN, DYN], @@ -1905,6 +1906,7 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { { mode: "required", type: DYN }, { mode: "optional", type: DYN, defaultValue: undefined }, ], + valueName: "debuglog", }, }, // The util/types spoke supplies the probe name to the shared brand check. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json new file mode 100644 index 000000000..04ed0d7a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-name-const-alias.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/function-name-const-alias.mjs b/tests/corpus/function-name-const-alias.mjs new file mode 100644 index 000000000..a534c50ec --- /dev/null +++ b/tests/corpus/function-name-const-alias.mjs @@ -0,0 +1,20 @@ +// The name of a function value read through a const binding: a builtin +// keeps Node's name (including when destructured), an anonymous function or +// arrow takes the binding's name through parentheses, and an alias of a +// named function keeps the function's name. +import * as path from "node:path"; +import * as processModule from "node:process"; + +const { cwd } = process; +const { cwd: renamed } = processModule; +const join = path.join; +const parenthesized = (function () {}); +const arrow = (() => 0); +const nested = ((function () {})); +function named() {} +const alias = named; +const aliasOfAlias = alias; +console.log(cwd.name, renamed.name, join.name); +console.log(parenthesized.name, arrow.name, nested.name); +console.log(alias.name, aliasOfAlias.name); +console.log(cwd === process.cwd, typeof cwd()); diff --git a/tests/harness/corpus-costs/function-name-const-alias.mjs.json b/tests/harness/corpus-costs/function-name-const-alias.mjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/function-name-const-alias.mjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } From 460bd6fb38d7af46f584e1567cdafc7ea986c991 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 12:38:19 -0700 Subject: [PATCH 31/47] Keep a generator method's generator ABI through the prototype-access widening A class whose methods are reachable through `X.prototype` widens its method signatures to checked values so a JS caller can hand anything across. The widening replaced every non-void return with DYN, which erased a GENERATOR method's generator type; the generator branch below then had no generator channels to build its metadata from and refused the whole class (SC2001 in `%init`, re-emitted as a runtime fence in a JS file). Every `assert.sameValue(c.m, C.prototype.m)` variant of the campaign therefore refused at runtime, 759 of them in the generator-method chapters alone. A generator's return kind is already a checked-crossable ABI: dynFrom boxes a generator by reference and dyn calls resume it through the dyn generator ops, so only the arguments need the widening. Guard the return widening to non-generator methods. Test262, 1,110 affected variants, before/after on the candidate (class/gen-method 52->76, class/async-gen-method 76->128, class/scope-gen-meth 0->8); no pass->fail and no typed-path delta. Co-Authored-By: Claude 5.5 --- .../src/frontend/lowering/lower-classes.ts | 10 +++- tests/corpus/dyn-generator-method-value.js | 53 +++++++++++++++++++ .../dyn-generator-method-value.js.json | 1 + 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/dyn-generator-method-value.js create mode 100644 tests/harness/corpus-costs/dyn-generator-method-value.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..014bbc71a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -3451,7 +3451,15 @@ export function collectClassShapeInner( if (shape.bodyType) shape.bodyType = DYN; } ft.params = shapes.map((shape) => shape.type); - if (ft.ret.kind !== "void") ft.ret = DYN; + // A GENERATOR method's result is the suspended generator its spawn + // wrapper answers, so the generator kind IS the checked-crossable + // ABI: dynFrom boxes a generator by reference and dyn calls resume + // it through the dyn generator ops. Replacing it with plain DYN + // would leave the method no representation at all (the generator + // branch below needs the channels to build its metadata), which + // fenced every `c.m` read on a prototype-accessed class. Only the + // ARGUMENTS cross as checked values here. + if (ft.ret.kind !== "void" && member.asteriskToken === undefined) ft.ret = DYN; } // JS subclasses can add parameters to a method. Reserve checked // argument slots in the base before constructing its vtable; shorter diff --git a/tests/corpus/dyn-generator-method-value.js b/tests/corpus/dyn-generator-method-value.js new file mode 100644 index 000000000..f1565aab0 --- /dev/null +++ b/tests/corpus/dyn-generator-method-value.js @@ -0,0 +1,53 @@ +/* A generator method VALUE crossing the checked-value boundary. + * + * A class whose methods are reachable through its prototype object answers + * its methods as checked (dynamic) values: the value read off an instance is + * the same function object the prototype carries, a checked callback can hold + * it and call it, and the generator object it produces keeps the generator + * protocol across the box. Node is the oracle. */ +class Seq { + constructor(tag) { + this.tag = tag; + } + *items(n) { + for (let i = 0; i < n; i++) yield this.tag + ":" + i; + return n; + } + async *later(n) { + yield n + 1; + return n; + } +} + +function sameValue(actual, expected, label) { + if (actual !== expected) throw new Error("not the same: " + label); +} +function typeofOf(value) { + return typeof value; +} +function callWith(receiver, fn, ...args) { + return fn.apply(receiver, args); +} + +const seq = new Seq("a"); +sameValue(seq.items, Seq.prototype.items, "instance method value"); +sameValue(seq.items, seq.items, "repeated read"); +sameValue(seq.later, Seq.prototype.later, "async instance method value"); +console.log(typeofOf(seq.items), typeofOf(seq.later)); + +const gen = callWith(seq, seq.items, 2); +console.log(gen.next().value, gen.next().value); +const finished = gen.next(); +console.log(finished.value, finished.done); + +// The direct call answers its own generator object. (Identity and reflection +// on a generator object are separate, still-fenced operations.) +const direct = seq.items(1); +console.log(direct.next().value); + +// An async generator value crosses the same boundary. +callWith(seq, seq.later, 4) + .next() + .then(function (step) { + console.log("async", step.value, step.done); + }); diff --git a/tests/harness/corpus-costs/dyn-generator-method-value.js.json b/tests/harness/corpus-costs/dyn-generator-method-value.js.json new file mode 100644 index 000000000..cc16fb950 --- /dev/null +++ b/tests/harness/corpus-costs/dyn-generator-method-value.js.json @@ -0,0 +1 @@ +{ "plain": 1349, "san": 2585 } From 34c39e54436875a00bb336fd24dee3cc456540de Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 16:59:25 -0700 Subject: [PATCH 32/47] Throw on empty patterns over checked-dynamic sources An empty binding pattern still performs its one observable step: RequireObjectCoercible for `{}` and GetIterator for `[]`. Over a statically non-nullish source that step is invisible, which is why the empty-pattern lowering emitted nothing; a checked-dynamic source can be null or undefined at runtime, where V8 throws. Emit the runtime null/undefined test for dyn sources in the empty-pattern branch, and let a JS-file empty object pattern take the source-spelling message dynDestrCheck already produces for non-empty ones. Test262 (static-adapted-v6, --workers 8), affected chapters before -> after: language/statements/class/dstr pass 1518 -> 1526, fail 424 -> 416; language/expressions/class/dstr pass 1518 -> 1526, fail 424 -> 416. No pass -> fail. Co-Authored-By: Claude 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 35 ++++++- ...empty-pattern-requires-object-coercible.js | 96 +++++++++++++++++++ ...-pattern-requires-object-coercible.js.json | 1 + 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/8516-empty-pattern-requires-object-coercible.js create mode 100644 tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..3cb9ecaee 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2617,7 +2617,7 @@ export function lowerDestructuringDecl( init.type.kind === "dyn" && ts.isObjectBindingPattern(decl.name) && isJsSourceFile(ts.getSourceFileOfNode(decl)) && - startsWithRuntimeKey(lowerer, decl.name) && + (decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) && destrSpellingOf(decl.initializer) !== null; if ( jsRuntimeKeyCheck || @@ -3181,6 +3181,39 @@ export function lowerBindingPattern( // every statically non-nullish kind, record or not (the assignment // path's rule); possibly-nullish sources would throw in JS and keep // a fence. + // + // A CHECKED-DYNAMIC source still owes that step at runtime: an empty + // object pattern over null/undefined throws RequireObjectCoercible, + // and an empty array pattern throws GetIterator's "is not iterable". + // Nothing else about the pattern is observable, so the fence is the + // whole lowering. + if (srcType.kind === "dyn") { + const unitLoc = locOf(pattern); + const isObject = ts.isObjectBindingPattern(pattern); + for (const unit of ["null", "undefined"] as const) { + // V8 describes an argument-derived source by its type ("object + // null"); a bare non-empty pattern names its source instead, but + // those reads throw on their own before this check could fire. + const described = unit === "null" ? "object null" : "undefined"; + const message = isObject + ? `Cannot destructure '${described}' as it is ${unit}.` + : `${described} is not iterable (cannot read property Symbol(Symbol.iterator))`; + out.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value: srcRef(), type: BOOL, loc: unitLoc }, + then: [ + { + kind: "exprStmt", + expr: nodeThrowExpr(1, "", message, VOID, unitLoc), + loc: unitLoc, + }, + ], + else_: null, + loc: unitLoc, + }); + } + return; + } if ( isUnitType(srcType) || (srcType.kind === "union" && diff --git a/tests/corpus/8516-empty-pattern-requires-object-coercible.js b/tests/corpus/8516-empty-pattern-requires-object-coercible.js new file mode 100644 index 000000000..069ec2f83 --- /dev/null +++ b/tests/corpus/8516-empty-pattern-requires-object-coercible.js @@ -0,0 +1,96 @@ +// An empty destructuring pattern still performs its one observable step: +// RequireObjectCoercible for `{}` and GetIterator for `[]`. A checked-dynamic +// source reaches that step at runtime, so null and undefined throw the same +// TypeError Node does while every other value — including primitives, which +// ToObject wraps — is accepted. The throws are printed through +// `instanceof` rather than the message, since V8 spells the source +// differently per position (an argument is "object null", a declaration +// names its expression). + +function report(label, fn) { + try { + fn(); + console.log(label, "no-throw"); + } catch (error) { + console.log(label, error instanceof TypeError ? "TypeError" : "other"); + } +} + +function acceptsWhatever(value) { + return value; +} + +report("object-null", () => { + const source = acceptsWhatever(null); + const {} = source; +}); +report("object-undefined", () => { + const source = acceptsWhatever(undefined); + const {} = source; +}); + +function objectParameter({}) { + return "bound"; +} +report("param-null", () => { + objectParameter(null); +}); +report("param-undefined", () => { + objectParameter(undefined); +}); +report("param-number", () => { + console.log("param-number returned", objectParameter(7)); +}); +report("param-string", () => { + console.log("param-string returned", objectParameter("text")); +}); + +function arrayParameter([]) { + return "bound"; +} +report("array-param-null", () => { + arrayParameter(null); +}); +report("array-param-undefined", () => { + arrayParameter(undefined); +}); + +function nestedObject({ inner: {} }) { + return "nested"; +} +report("nested-null", () => { + nestedObject({ inner: null }); +}); +report("nested-undefined", () => { + nestedObject({ inner: undefined }); +}); +report("nested-object", () => { + console.log("nested-object returned", nestedObject({ inner: {} })); +}); + +// A loop head rebinds the pattern per iteration, exactly like a declaration. +report("for-of-object", () => { + for (const {} of acceptsWhatever([null])) { + console.log("never"); + } +}); + +// Non-nullish primitives are accepted by both pattern kinds. +report("primitives", () => { + const numbers = [0, "", false, 1, "x"]; + for (const value of numbers) { + const {} = value; + console.log("destructured", JSON.stringify(value), typeof value); + } +}); + +// The assignment form keeps its own (already exact) check. +report("assignment-null", () => { + let bound; + ({} = bound); +}); +report("assignment-number", () => { + let bound = 5; + ({} = bound); + console.log("assignment-number finished"); +}); diff --git a/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..e683aaf34 --- /dev/null +++ b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 24000 } From 083d8e7aa33e5996b4132fbd5a2882b56967feb0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:42:33 -0700 Subject: [PATCH 33/47] Record the ts7 baselines of the new corpus programs Co-Authored-By: Claude 5.5 --- .../8516-empty-pattern-requires-object-coercible.js.json | 6 ++++++ .../tests/corpus/dyn-generator-method-value.js.json | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..bff29a8e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8516-empty-pattern-requires-object-coercible.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json new file mode 100644 index 000000000..1e51be568 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dyn-generator-method-value.js" + ], + "diags": [] +} From 9eaa210e1fffce3f630e96039dff4b75b9e869fe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:54:21 -0700 Subject: [PATCH 34/47] Narrow filtered union elements into a record with an arm's fields A written type predicate whose target is no arm of the element union but the same fields as one, with a nested union field narrowed (Model<"a"> from the Model arm), was refused. The kept elements of that arm now convert field by field, the nested union by its tag; any other kept element still throws the predicate TypeError. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/filter-predicates.ts | 36 +++++++++++++-- .../filter-predicate-model-narrow.ts.json | 6 +++ tests/corpus/filter-predicate-model-narrow.ts | 44 +++++++++++++++++++ .../filter-predicate-model-narrow.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json create mode 100644 tests/corpus/filter-predicate-model-narrow.ts create mode 100644 tests/harness/corpus-costs/filter-predicate-model-narrow.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..18e6ac984 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -7,6 +7,8 @@ * * - a union element to one of its arms, or to a union of some of its * arms, by its tag; + * - a union element to a record with the same fields as one of its arms, + * through that arm; * - a checked-dynamic element by dynCheck; * - a class instance to a subclass by its class; * - a tuple or record to one with the same fields, field by field. @@ -229,10 +231,35 @@ function unionConversion( if (targetArms === null) return null; const body: IrStmt[] = []; let kept = 0; + let recordArmConversions = 0; for (let tag = 0; tag < source.arms.length; tag++) { const arm = source.arms[tag]!; const targetTag = targetArms.findIndex((t) => typeEquals(t, arm)); - if (targetTag < 0) continue; + if (targetTag < 0) { + // A record target that is no arm itself but the same fields as one + // (`Model<"a">` from the `Model` arm, whose nested union field + // the predicate narrows): that arm converts field by field. + if (to.kind !== "record" || arm.kind !== "record") continue; + const narrowedArm: IrExpr = { + kind: "unionNarrow", + unionId, + tag, + value: input, + type: arm, + loc, + }; + const converted = conversionBody(lowerer, narrowedArm, arm, to, typeText, loc); + if (converted === null) continue; + recordArmConversions++; + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc }, + then: converted, + else_: null, + loc, + }); + continue; + } kept++; const payload: IrExpr = isUnitType(arm) ? { kind: "unitLit", unit: arm.kind === "undefinedT" ? "undefined" : "null", type: arm, loc } @@ -249,8 +276,11 @@ function unionConversion( loc, }); } - // Every arm the target names must be one of the union's own arms. - if (kept === 0 || kept !== targetArms.length) return null; + // Every arm the target names must be one of the union's own arms, or a + // record target converts from at least one arm. + if (recordArmConversions > 0) { + if (kept !== 0) return null; + } else if (kept === 0 || kept !== targetArms.length) return null; body.push( typeError( `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..c74a7d818 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/filter-predicate-model-narrow.ts" + ], + "diags": [] +} diff --git a/tests/corpus/filter-predicate-model-narrow.ts b/tests/corpus/filter-predicate-model-narrow.ts new file mode 100644 index 000000000..4c97445d0 --- /dev/null +++ b/tests/corpus/filter-predicate-model-narrow.ts @@ -0,0 +1,44 @@ +// `.filter` with a written type predicate whose target is no arm of the +// element union but the same fields as one, with a nested union field the +// predicate narrows (`Model<"a">` from the `Model` arm). Kept elements +// convert field by field; a kept element of another shape would throw. + +interface CompatA { + strict?: boolean; +} +interface CompatB { + effort?: string; +} +interface CompatMap { + a: CompatA; + b: CompatB; +} +type Api = keyof CompatMap; +interface Model { + kind: "chat"; + api: A; + id: string; + window: number; + compat?: CompatMap[A]; +} +interface Image { + kind: "image"; + id: string; +} +type AnyModel = Model | Image; + +function restore(stored: AnyModel[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} + +const stored: AnyModel[] = [ + { kind: "chat", api: "a", id: "1", window: 8, compat: { strict: true } }, + { kind: "image", id: "2" }, + { kind: "chat", api: "b", id: "3", window: 4, compat: { effort: "hi" } }, + { kind: "chat", api: "a", id: "4", window: 2 }, +]; +const restored = restore(stored); +for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model); +console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0)); + +export {}; diff --git a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..2440b7ddb --- /dev/null +++ b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 4307, "san": 81017 } From 2acd199fbebf6e29ccef02ec1383f85526ae359f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:54:26 -0700 Subject: [PATCH 35/47] Give an async object-literal method its contextual return type An async method in an object literal with no return annotation and a contextual signature inferred its own (narrower) literal type, while its `return` literal was built at the contextually typed record: the return statement then had to convert the wider record into the narrower literal, which fails with SC2002. The method now takes its return type from the contextual property's call signature, as the arrow form does. Sync methods are unchanged; they already adopt their slot. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/function-returns.ts | 28 ++++++++++++++++++- ...nc-object-method-contextual-return.ts.json | 6 ++++ .../async-object-method-contextual-return.ts | 14 ++++++++++ ...nc-object-method-contextual-return.ts.json | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json create mode 100644 tests/corpus/async-object-method-contextual-return.ts create mode 100644 tests/harness/corpus-costs/async-object-method-contextual-return.ts.json diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index c692742a8..4ab067fc9 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -164,7 +164,33 @@ export function declaredReturnType( if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && ts.isGetAccessorDeclaration(decl)) return DYN; const sig = lowerer.checker.getSignatureFromDeclaration(decl); if (!sig) lowerer.unsupported("SC1090", decl, "this function form"); - const retTsType = lowerer.checker.getReturnTypeOfSignature(sig); + let retTsType = lowerer.checker.getReturnTypeOfSignature(sig); + // An ASYNC object-literal method with no return annotation and a + // contextual signature takes its return from that signature, as an async + // arrow does: the body's `return` builds at the contextually typed + // literal, so the checker's own inferred literal (narrower than the + // contextual record) could never hold it. + if ( + ts.isMethodDeclaration(decl) && + ts.isObjectLiteralExpression(decl.parent) && + decl.type === undefined && + decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true + ) { + const contextual = lowerer.checker.getContextualType(decl.parent); + const slot = + contextual === undefined || (!ts.isIdentifier(decl.name) && !ts.isStringLiteral(decl.name)) + ? undefined + : lowerer.checker.getPropertyOfType( + lowerer.checker.getNonNullableType(contextual), + decl.name.text, + ); + const signatures = + slot === undefined + ? [] + : lowerer.checker.getCallSignatures(lowerer.checker.getTypeOfSymbol(slot)); + if (signatures.length === 1) + retTsType = lowerer.checker.getReturnTypeOfSignature(signatures[0]!); + } // An inferred null/undefined return in JS can read a mutable checked-value // field. Keep the actual value instead of restricting it to the initializer. if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json new file mode 100644 index 000000000..5338193d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/async-object-method-contextual-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/async-object-method-contextual-return.ts b/tests/corpus/async-object-method-contextual-return.ts new file mode 100644 index 000000000..223063079 --- /dev/null +++ b/tests/corpus/async-object-method-contextual-return.ts @@ -0,0 +1,14 @@ +type Text = { type: "text"; text: string; textSignature?: string }; +type Image = { type: "image"; data: string; mimeType: string }; +interface ToolResult { content: (Text | Image)[]; details: D; isError?: boolean } +interface ToolDefinition { name: string; execute(id: string, q: string): Promise> } +function define(): ToolDefinition<{ loaded: string[] }> { + return { + name: "search", + async execute(_id, q) { + const text = `found ${q}`; + return { content: [{ type: "text", text }], details: { loaded: [q] } }; + }, + }; +} +define().execute("1", "x").then((r) => console.log(JSON.stringify(r))); diff --git a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json new file mode 100644 index 000000000..b0f0df482 --- /dev/null +++ b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json @@ -0,0 +1 @@ +{ "plain": 3268, "san": 6404 } From 8e74d0518d20dcebad64fbbe8591f209102a4bab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:05:21 -0700 Subject: [PATCH 36/47] Give an untyped empty Map the layout of the value it joins `x ?? { errors: new Map() }` left the empty map typed Map, which spread through the checker into `new Map(result.errors)` and the binding holding it, and every use failed with SC2020/SC1090. The fallback literal now builds as x's record, `new Map(source)` over such a join copies into the source's layout, an unannotated binding whose checker type differs only by that residue takes its initializer's layout, and Map methods on it convert into the receiver's layout. No map is converted between layouts: each value keeps the one layout it has. Co-Authored-By: Claude Opus 5.5 --- .../lowering/any-collection-residue.ts | 44 +++++++++++++++++++ .../lowering/containers/collection-methods.ts | 11 +++++ .../frontend/lowering/expressions/binary.ts | 43 ++++++++++++++++++ .../src/frontend/lowering/lower-classes.ts | 35 ++++++++++++++- .../src/frontend/lowering/lower-stmts.ts | 14 ++++++ .../corpus/map-untyped-empty-fallback.ts.json | 6 +++ tests/corpus/map-untyped-empty-fallback.ts | 41 +++++++++++++++++ .../map-untyped-empty-fallback.ts.json | 1 + 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/frontend/lowering/any-collection-residue.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json create mode 100644 tests/corpus/map-untyped-empty-fallback.ts create mode 100644 tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json diff --git a/packages/compiler/src/frontend/lowering/any-collection-residue.ts b/packages/compiler/src/frontend/lowering/any-collection-residue.ts new file mode 100644 index 000000000..7581c0fed --- /dev/null +++ b/packages/compiler/src/frontend/lowering/any-collection-residue.ts @@ -0,0 +1,44 @@ +import { type IrType, typeKey } from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Whether `actual` is `declared` with every `Map` or + * `Set` (TypeScript's `any` type arguments of an untyped empty + * collection, mapped to unknown) replaced by a map or set of a concrete + * layout, and with nothing else different. A union `declared` matches when + * `actual` is one of its record arms and every other arm is the residue + * form of that arm. */ +export function anyResidueReplacedBy(lowerer: Lowerer, declared: IrType, actual: IrType): boolean { + const residue = (t: IrType): boolean => + (t.kind === "map" && t.key.kind === "dyn" && t.value.kind === "dyn") || + (t.kind === "set" && t.elem.kind === "dyn"); + const replaces = (d: IrType, a: IrType, seen: Set): boolean => { + if (typeKey(d) === typeKey(a)) return true; + if (residue(d)) return a.kind === d.kind && !residue(a); + if (d.kind === "record" && a.kind === "record") { + const pair = `${d.shapeId}>${a.shapeId}`; + if (seen.has(pair)) return true; + seen.add(pair); + const from = lowerer.shapes.get(d.shapeId); + const to = lowerer.shapes.get(a.shapeId); + if (!from || !to || from.tuple || to.tuple || from.indexValue || to.indexValue) return false; + if (from.fields.length !== to.fields.length) return false; + return from.fields.every((field) => { + const other = to.fields.find((f) => f.name === field.name); + return other !== undefined && replaces(field.type, other.type, seen); + }); + } + return false; + }; + if (declared.kind === "union" && actual.kind === "record") { + const arms = lowerer.unions.get(declared.unionId)?.arms ?? []; + return ( + arms.some((arm) => typeKey(arm) === typeKey(actual)) && + arms.every( + (arm) => + typeKey(arm) === typeKey(actual) || + (arm.kind === "record" && replaces(arm, actual, new Set())), + ) + ); + } + return replaces(declared, actual, new Set()); +} diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index 21cc36016..c2fae508d 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -76,6 +76,17 @@ export function lowerMapMethodCall( if (!probedUntyped && !lowerer.isStdlibMember(access) && name !== "has") return null; const loc = locOf(call); const value = lowerer.lowerExpr(access.expression); + // A receiver the checker types `Map` (inference residue of an + // untyped `new Map()`) whose value has a concrete layout: its keys and + // values convert into that layout. + if ( + value.type.kind === "map" && + receiverIr.key.kind === "dyn" && + receiverIr.value.kind === "dyn" && + value.type.key.kind !== "dyn" && + value.type.value.kind !== "dyn" + ) + receiverIr = value.type; const receiver = lowerer.runtimeOptionalPropertyReceiver(access.expression, value, receiverIr, name) ?? value; if (receiver.type.kind !== "map") { diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..e20604f92 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -354,6 +354,31 @@ function literalDefault(node: ts.Expression): boolean { ); } +/** A fresh object literal (parentheses allowed) one of whose property + * values, at any literal depth, is `new Map()` or `new Set()` of the + * standard library with neither arguments nor type arguments. */ +function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boolean { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isObjectLiteralExpression(value)) return false; + return value.properties.nodes.some((property) => { + if (!ts.isPropertyAssignment(property)) return false; + let inner = property.initializer; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + if (ts.isObjectLiteralExpression(inner)) return untypedCollectionFallback(lowerer, inner); + if ( + !ts.isNewExpression(inner) || + !ts.isIdentifier(inner.expression) || + (inner.arguments?.nodes.length ?? 0) !== 0 || + (inner.typeArguments?.nodes.length ?? 0) !== 0 + ) + return false; + const symbol = lowerer.resolveValueSymbol(inner.expression); + const name = symbol ? ts.symbolName(symbol) : ""; + return symbol !== null && lowerer.isStdlibSymbol(symbol) && (name === "Map" || name === "Set"); + }); +} + export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -387,6 +412,24 @@ export function lowerNullishPair( // arm that has no first-class union representation. const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null; if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc }; + // `optionalRecord ?? { a: 1, items: new Map() }`: a fresh fallback literal + // whose untyped empty collections (`new Map()`, `new Set()` without type + // arguments, which the checker types `Map`) leave the checker's + // join as a second record arm. When the literal is assignable to the + // left's record, it builds as that record, so the collections take its + // element types and the result keeps one layout. + if ( + rest.length === 1 && + rest[0]!.kind === "record" && + untypedCollectionFallback(lowerer, expr.right) && + lowerer.checker.isTypeAssignableTo( + lowerer.typeOf(expr.right), + lowerer.checker.getNonNullableType(lowerer.typeOf(expr.left)), + ) + ) { + const right = lowerer.lowerExprExpecting(expr.right, 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/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..95364441a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -133,7 +133,11 @@ import { isAssertClassNew } from "./assert-instances.js"; import { isCjsRequireGlobal } from "./lower-node-module.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { initializedCollectionType } from "./js-collection-types.js"; -import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; +import { + lowerMapSeedNew, + lowerSetSeedNew, + mapFromSeedValue, +} from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js"; import { lowerSearchParamsNew } from "./builtins/url.js"; @@ -11062,6 +11066,35 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = ctxMapped; } } + // `new Map(source)` the checker types `Map` because the + // source's checker type joins an untyped `new Map()` with a typed map + // (a union of map types): the copy takes the layout the source value + // actually has. + if ( + seedArg && + !entriesLit && + mapped?.kind === "map" && + mapped.key.kind === "dyn" && + mapped.value.kind === "dyn" && + (expr.typeArguments?.nodes.length ?? 0) === 0 && + !isJsSourceFile(ts.getSourceFileOfNode(expr)) && + lowerer.mapTypeOf(lowerer.typeOf(seedArg))?.kind === "union" + ) { + const source = lowerer.lowerExpr(seedArg); + if ( + source.type.kind === "map" && + source.type.key.kind !== "dyn" && + source.type.value.kind !== "dyn" + ) { + const copied = mapFromSeedValue(lowerer, source, source.type); + if (copied) return copied; + } + lowerer.noLowering( + "new Map(entries)", + expr, + "a source whose map layouts differ (give the empty `new Map()` its type arguments)", + ); + } if (seedArg && !entriesLit && mapped?.kind === "map") { const seeded = lowerMapSeedNew(lowerer, seedArg, mapped); if (seeded) return seeded; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..5f8236fa8 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 { anyResidueReplacedBy } from "./any-collection-residue.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -6426,6 +6427,19 @@ export function lowerVarDecl( typeInvolvesWebStream(lowerer, lowerer.typeOf(decl.name)))) ) type = DYN; + // An unannotated TypeScript binding whose checker type carries `any` + // inference residue (`Map` from an untyped `new Map()` joined + // into the initializer's type) takes the initializer's own layout when + // that layout replaces only the residue: `any` admits every use, and the + // value has that layout. + if ( + !decl.type && + type !== null && + !isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !typeEquals(type, init.type) && + anyResidueReplacedBy(lowerer, type, init.type) + ) + type = init.type; // A const whose initializer has another record or array layout than the // binding's type keeps the initializer's layout: converting would copy the // value, so the binding would not be the value its initializer names. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json new file mode 100644 index 000000000..364970fee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/map-untyped-empty-fallback.ts" + ], + "diags": [] +} diff --git a/tests/corpus/map-untyped-empty-fallback.ts b/tests/corpus/map-untyped-empty-fallback.ts new file mode 100644 index 000000000..e9506dac1 --- /dev/null +++ b/tests/corpus/map-untyped-empty-fallback.ts @@ -0,0 +1,41 @@ +// An untyped `new Map()` in a `??` fallback literal: the fallback builds +// as the left operand's record, so the empty map takes its key and value +// types. `new Map(result.errors)` then copies a typed map, and writes to +// the copy (directly and from a callback) keep that layout. + +interface RefreshResult { + aborted: boolean; + errors: ReadonlyMap; +} + +async function refresh(fail: boolean): Promise { + if (fail) return undefined; + const errors = new Map(); + errors.set("openai", new Error("boom")); + return { aborted: false, errors }; +} + +async function run(fail: boolean, providers: string[]): Promise<{ aborted: boolean; errors: Map }> { + const result = (await refresh(fail)) ?? { aborted: fail, errors: new Map() }; + const errors = new Map(result.errors); + errors.set("local", new Error("extra")); + await Promise.all( + providers.map(async (provider) => { + if (provider.startsWith("bad")) errors.set(provider, new Error(`${provider} failed`)); + }), + ); + return { aborted: result.aborted, errors }; +} + +async function main(): Promise { + for (const fail of [false, true]) { + const r = await run(fail, ["ok", "bad-a", "bad-b"]); + const names = [...r.errors.keys()].sort().join(","); + console.log(r.aborted, names, r.errors.get("local")?.message, r.errors.get("bad-b")?.message); + } + const source = new Map([["a", 1]]); + const copy = new Map(source); + copy.set("b", 2); + console.log(source.size, copy.size, copy !== source); +} +void main(); diff --git a/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json b/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json new file mode 100644 index 000000000..ac2bc9413 --- /dev/null +++ b/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json @@ -0,0 +1 @@ +{ "plain": 3815, "san": 6037 } From c3474cfa7ae6d0ef1e6620040aa5237ecce641f8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:30:01 -0500 Subject: [PATCH 37/47] Test that receivers are evaluated once and before effectful keys Adds the corpus program receiver-before-effectful-key.ts, with its corpus cost and order baseline: - a tuple read with a runtime index that rebinds the tuple or replaces the field holding it; - a keyed read through a finite union of field names whose key rebinds the record or replaces the field holding it; - an inherited-key `in` test on a computed null-prototype receiver, which runs the receiver once. Each case reads the receiver as evaluated before the key, as in Node. --- .../receiver-before-effectful-key.ts.json | 6 ++ tests/corpus/receiver-before-effectful-key.ts | 59 +++++++++++++++++++ .../receiver-before-effectful-key.ts.json | 1 + 3 files changed, 66 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json create mode 100644 tests/corpus/receiver-before-effectful-key.ts create mode 100644 tests/harness/corpus-costs/receiver-before-effectful-key.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json new file mode 100644 index 000000000..b44d5a127 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/receiver-before-effectful-key.ts" + ], + "diags": [] +} diff --git a/tests/corpus/receiver-before-effectful-key.ts b/tests/corpus/receiver-before-effectful-key.ts new file mode 100644 index 000000000..496c9c4c3 --- /dev/null +++ b/tests/corpus/receiver-before-effectful-key.ts @@ -0,0 +1,59 @@ +// A receiver is evaluated once, before its key, even when the key's +// evaluation rebinds the receiver's binding or has other effects: a tuple +// read with a runtime index, a keyed read through a finite union of field +// names, and an inherited-key `in` test on a computed index-signature +// receiver. + +const log: string[] = []; + +// Tuple read with a runtime index that rebinds the tuple. +let pair: [string, number] = ["first", 1]; +function rebindPair(i: number): number { + log.push(`rebind pair ${i}`); + pair = ["second", 2]; + return i; +} +const viaIndex = pair[rebindPair(0)]; +console.log("tuple", viaIndex, pair[0]); + +// A field holding a tuple, replaced by the index. +const holder: { t: [number, number, number] } = { t: [10, 20, 30] }; +function replaceHeld(i: number): number { + holder.t = [-1, -2, -3]; + return i; +} +console.log("tuple field", holder.t[replaceHeld(2)], holder.t[2]); + +// Keyed read through a finite union of keys naming a subset of the fields, +// with a key that rebinds the local or replaces the field holding the record. +type Row = { width?: number; label: string; tags: string[] }; +function finiteKeys(): void { + let row: Row = { width: 640, label: "old", tags: [] }; + const pick = (): "width" | "label" => { + log.push("pick key"); + row = { width: 1, label: "new", tags: ["x"] }; + return "label"; + }; + const picked = row[pick()]; + console.log("keyed", picked, row.label); + const box: { row: Row } = { row: { label: "boxed", tags: [] } }; + const replace = (): "width" | "label" => { + box.row = { width: 7, label: "replaced", tags: [] }; + return "width"; + }; + const width = box.row[replace()]; + console.log("keyed field", width, box.row.width); +} +finiteKeys(); + +// `in` on an inherited key of a computed null-prototype record: the +// receiver call runs once. +let groups = 0; +function grouped(): Partial> { + groups++; + log.push("grouped"); + return Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); +} +console.log("in", "toString" in grouped(), "hasOwnProperty" in grouped(), groups); + +console.log(log.join(" | ")); diff --git a/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json b/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } From 269ef9f0bc99d3469529737ed1b86518fe90491a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:43:27 -0500 Subject: [PATCH 38/47] Read each layout training profile when its build finishes refresh-cli-layout.mjs kept the raw profile of every cold training build until the end of the run, about 23 MB per build and 8.7 GB for the 399 builds. When the disk filled, the profile runtime wrote partial files, the compiler still exited with 0, and reading a partial file threw a RangeError past the end of its counters. Each cold profile is now read and deleted when its build finishes, so the work directory stays under 200 MB. The script throws when a training run reports `LLVM Profile Error`, naming the build and the write error, and when a profile is shorter than its header describes. --- scripts/refresh-cli-layout.mjs | 47 +++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/scripts/refresh-cli-layout.mjs b/scripts/refresh-cli-layout.mjs index cb396015f..4b7437a0d 100644 --- a/scripts/refresh-cli-layout.mjs +++ b/scripts/refresh-cli-layout.mjs @@ -92,9 +92,11 @@ const scriptc = join(cli, "bin/scriptc"); const baseEnv = { ...process.env, SCRIPTC_TIMING: "1" }; delete baseEnv["SCRIPTC_LLVM_HELPER"]; -/** Runs the instrumented compiler, writing its profile to `options.profile`. */ +/** Runs the instrumented compiler, writing its profile to `options.profile`. + * Throws when the profile runtime reports that it could not write the + * profile (a full disk, for example): the compiler still exits with 0. */ function run(args, options) { - return new Promise((done) => { + return new Promise((done, fail) => { const child = spawn(scriptc, args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], @@ -109,7 +111,9 @@ function run(args, options) { child.stderr.on("data", (chunk) => (text += chunk)); child.on("close", (code) => { appendFileSync(log, `$ scriptc ${args.join(" ")}\n${text}exit ${code}\n`); - done({ ok: code === 0, text }); + const profileError = /^LLVM Profile Error: .*$/m.exec(text)?.[0]; + if (profileError !== undefined) fail(new Error(`scriptc ${args.join(" ")}: ${profileError}`)); + else done({ ok: code === 0, text }); }); }); } @@ -142,8 +146,24 @@ const training = [ // rebuild that missed a cache (it shows no `frontend-cache-hit` or // `executable-cache-hit` phase) is left out. The first builds each write // their own profile (cold--*), merged across the build's processes. +// Each profile is read and deleted when its build finishes: a profile is +// about 23 MB, and keeping every build's until the end takes about 9 GB. const warm = new Set(); let skipped = 0; +// For each function, how many of the first builds ran it. +const runs = new Map(); +let coldBuilds = 0; +function countColdRun(index) { + const profiles = readdirSync(raw).filter((name) => name.startsWith(`cold-${index}-`)); + if (profiles.length === 0) return; + const ran = new Set(); + for (const name of profiles) { + for (const fn of executedFunctions(join(raw, name))) ran.add(fn); + rmSync(join(raw, name)); + } + for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1); + coldBuilds++; +} async function warmRun(args, cache, name, cacheHits) { const profile = join(raw, `warm-${name}.profraw`); const { ok, text } = await run(args, { cwd: root, cache, profile }); @@ -166,6 +186,7 @@ const passed = await pool( cache, profile: join(raw, `cold-${index}-%1m.profraw`), }); + countColdRun(index); if (ok) await warmRun(args, cache, String(index), ["frontend-cache-hit", "executable-cache-hit"]); rmSync(cache, { recursive: true, force: true }); @@ -196,6 +217,12 @@ function executedFunctions(path) { const dataStart = 16 * 8 + binaryIdsBytes; const countersStart = dataStart + records * recordBytes + beforeCounters; const namesStart = countersStart + counters * 8 + afterCounters + bitmapBytes + afterBitmap; + // The profile runtime reports a failed write (a full disk, for example) on + // stderr, and the compiler still exits with 0, leaving a partial file. + if (buf.length < namesStart + namesBytes) + throw new Error( + `${path} is ${buf.length} bytes, shorter than the ${namesStart + namesBytes} bytes its header describes`, + ); const countersDelta = buf.readBigInt64LE(10 * 8); const names = new Map(); let at = namesStart; @@ -241,18 +268,8 @@ function executedFunctions(path) { // tier 2 for functions at least 90% of the builds run, tier 3 for 60%, and so // on; tier 7 holds the functions fewer than 3% of the builds run. const COLD_TIERS = [0.9, 0.6, 0.3, 0.1, 0.03]; -const coldRuns = new Map(); -for (const name of readdirSync(raw)) { - const program = /^cold-(\d+)-/.exec(name)?.[1]; - if (program === undefined) continue; - const ran = coldRuns.get(program) ?? new Set(); - for (const fn of executedFunctions(join(raw, name))) ran.add(fn); - coldRuns.set(program, ran); -} -const runs = new Map(); -for (const ran of coldRuns.values()) for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1); const coldTier = (count) => { - const share = count / coldRuns.size; + const share = count / coldBuilds; const index = COLD_TIERS.findIndex((least) => share >= least); return 2 + (index < 0 ? COLD_TIERS.length : index); }; @@ -269,7 +286,7 @@ const perTier = new Map(); for (const line of lines) perTier.set(line.split(" ")[0], (perTier.get(line.split(" ")[0]) ?? 0) + 1); console.log( - `wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldRuns.size} builds (${statSync(output).size} bytes)`, + `wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldBuilds} builds (${statSync(output).size} bytes)`, ); rmSync(builds, { recursive: true, force: true }); if (values.work === undefined) rmSync(work, { recursive: true, force: true }); From 01a9cce36d933e8cde6de300469fe6112a48b020 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 22:50:15 -0500 Subject: [PATCH 39/47] Name alias type nodes by category The emitter named each TBAA type node `field.`, numbered in the order the module's functions first use the categories, so an edit that adds a field or memory category renumbered every later one. Every function that loads or stores those fields then changed text, and its program partition was compiled again. Each type node is now named by its category (`field:`, `array:header`, `global:`). The set of nodes and their structure are unchanged, so the generated code is the same. --- .../compiler/src/backend/llvm/receivers.ts | 12 ++- .../test/alias-metadata-names.test.ts | 75 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/alias-metadata-names.test.ts diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index e2a362bf7..240e1946a 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -11,6 +11,7 @@ import { import type { LlValue } from "./expr-context.js"; import { guardedValue } from "./borrowed-receivers.js"; import { InternalCompilerError } from "../../errors.js"; +import { llvmQuoted } from "../literals.js"; import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js"; import { emitBorrowedMapGet, @@ -459,12 +460,19 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void { } } +/** The alias metadata of the module's field and memory categories. Each type + * node is named by its category (`field:`, `array:header`, ...), not + * by its number: numbers follow the order in which the module's functions + * first use the categories, so an edit that adds one renumbers the rest. The + * LLVM helper renumbers each program partition's metadata, so a partition + * whose functions are unchanged keeps the same text and its cached + * artifacts. */ export function fieldAliasMetadata(emitter: LlEmitter): string[] { if (emitter.fieldAliasTags.size === 0) return []; const lines = ['!0 = !{!"scriptc native fields"}']; - for (const tag of emitter.fieldAliasTags.values()) { + for (const [category, tag] of emitter.fieldAliasTags) { lines.push( - `!${tag - 1} = !{!"field.${tag}", !0, i64 0}`, + `!${tag - 1} = !{!${llvmQuoted(category)}, !0, i64 0}`, `!${tag} = !{!${tag - 1}, !${tag - 1}, i64 0}`, ); } diff --git a/packages/compiler/test/alias-metadata-names.test.ts b/packages/compiler/test/alias-metadata-names.test.ts new file mode 100644 index 000000000..3674ce50b --- /dev/null +++ b/packages/compiler/test/alias-metadata-names.test.ts @@ -0,0 +1,75 @@ +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 { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +async function emit(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-alias-names-")); + 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 }); + } +} + +/** The alias type names that a function's tagged loads and stores use, in order. */ +function aliasNames(llvm: string, symbol: string): string[] { + const found = new RegExp(`^define [^\\n]*@${symbol}\\([^]*?^}`, "m").exec(llvm); + expect(found, symbol).not.toBeNull(); + const node = (id: string) => new RegExp(`^!${id} = !\\{(.*)\\}$`, "m").exec(llvm)?.[1] ?? ""; + return [...found![0].matchAll(/!tbaa !(\d+)/g)].map((tag) => { + const type = /^!(\d+),/.exec(node(tag[1]!))?.[1] ?? ""; + return /^!"([^"]*)"/.exec(node(type))?.[1] ?? ""; + }); +} + +const points = ` +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} +function sum(points: Point[]): number { + let total = 0; + for (const p of points) total += p.x * p.y; + return total; +} +`; + +test("alias type nodes are named by category, so a field added earlier leaves the others' names", async () => { + const before = await emit( + `${points}const all = [new Point(1, 2), new Point(3, 4)];\nconsole.log(sum(all));\n`, + ); + // A class whose field the module uses before Point's. + const after = await emit(` +class Weight { + grams: number; + constructor(grams: number) { + this.grams = grams; + } +} +function heaviest(weights: Weight[]): number { + let most = 0; + for (const w of weights) most = Math.max(most, w.grams); + return most; +} +console.log(heaviest([new Weight(5), new Weight(7)])); +${points}const all = [new Point(1, 2), new Point(3, 4)]; +console.log(sum(all)); +`); + const names = aliasNames(before, "sc_b?f_sum"); + expect(names.length).toBeGreaterThan(0); + expect(names.every((name) => name !== "" && !/^field\.\d+$/.test(name))).toBe(true); + expect(aliasNames(after, "sc_b?f_sum")).toEqual(names); +}); From a64b5b082050b6879bbd0dc1b218c21122490838 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:01:16 -0500 Subject: [PATCH 40/47] Keep saturating loops whose copies cannot share the inner loop The jam of a short constant-count loop now leaves the loop as it is when: - the inner loop's body assigns the accumulator other than by `acc = acc | C`. An iteration that runs past its exit could clear bits after the accumulator was full. - the inner loop's body assigns the inner counter. The copies share the counter, so one copy's write changed the iterations of the others. - the inner loop's start or bound reads the outer counter or a local of the outer iteration. The copies share the inner loop's start and bound, which read neither the counter's value nor the copies' renamed locals. The unit test rejects each shape, and the corpus program adds a function for each that matches Node's output. --- .../src/ir/saturating-loop-jams.test.ts | 46 +++++++++-- .../compiler/src/ir/saturating-loop-jams.ts | 55 ++++++++----- tests/corpus/saturating-loop-jams.ts | 79 +++++++++++++++++++ 3 files changed, 155 insertions(+), 25 deletions(-) diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts index b3d1d0fe9..e25b17e80 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.test.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -19,10 +19,16 @@ const bin = (op: Op, left: IrExpr, right: IrExpr): IrExpr => ({ const decl = (id: string, init: IrExpr): IrStmt => ({ loc, kind: "varDecl", localId: id, init }); const set = (id: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: id, value }); const stepBy = (id: string, by: number): IrStmt => set(id, bin("+", ref(id), num(by))); -const loop = (id: string, end: IrExpr, by: number, body: IrStmt[]): IrStmt & { kind: "for" } => ({ +const loop = ( + id: string, + end: IrExpr, + by: number, + body: IrStmt[], + start: IrExpr = num(0), +): IrStmt & { kind: "for" } => ({ loc, kind: "for", - init: decl(id, num(0)), + init: decl(id, start), cond: bin("<", ref(id), end), update: stepBy(id, by), body, @@ -61,7 +67,15 @@ function lane(z: string, c: string, bit: number, full = 3, extra: IrStmt[] = []) } function nest( - options: { full?: number; post?: IrStmt[]; preExtra?: IrStmt[]; copies?: number } = {}, + options: { + full?: number; + post?: IrStmt[]; + preExtra?: IrStmt[]; + innerExtra?: IrStmt[]; + innerStart?: IrExpr; + innerEnd?: IrExpr; + copies?: number; + } = {}, ): IrStmt[] { const post = options.post ?? [set("res", bin("|", bin("<<", ref("res"), num(2)), ref("b")))]; return [ @@ -73,10 +87,17 @@ function nest( decl("z2", ref("c2")), decl("b", num(0)), ...(options.preExtra ?? []), - loop("j", num(50), 1, [ - ...lane("z1", "c1", 2, options.full), - ...lane("z2", "c2", 1, options.full), - ]), + loop( + "j", + options.innerEnd ?? num(50), + 1, + [ + ...lane("z1", "c1", 2, options.full), + ...lane("z2", "c2", 1, options.full), + ...(options.innerExtra ?? []), + ], + options.innerStart, + ), ...post, ]), ]; @@ -162,6 +183,17 @@ test("keeps loops whose exits or values do not allow running iterations together reject(nest({ post: [set("res", bin("+", ref("res"), ref("z1")))] })); // `pre` reads what `post` writes. reject(nest({ preExtra: [decl("seed", ref("res"))] })); + // The inner loop writes the accumulator other than by an OR: a later + // iteration can clear bits after an exit. + reject(nest({ innerExtra: [set("b", num(0))] })); + reject(nest({ innerExtra: [set("b", bin("+", ref("b"), num(4)))] })); + // The inner loop's body advances its counter, which the copies share. + reject(nest({ innerExtra: [stepBy("j", 1)] })); + // The inner loop's start or bound reads the counter or a local of the + // iteration. + reject(nest({ innerStart: ref("i") })); + reject(nest({ innerEnd: bin("+", num(10), ref("i")) })); + reject(nest({ preExtra: [decl("start", num(3))], innerStart: ref("start") })); // One iteration, or more than eight. reject(nest({ copies: 1 })); reject(nest({ copies: 9 })); diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts index ae4bd38c8..96973a483 100644 --- a/packages/compiler/src/ir/saturating-loop-jams.ts +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -121,6 +121,20 @@ function pureStatements(list: readonly IrStmt[], locals: ReadonlyMap= 0 && bits <= 0x7fffffff ? bits : null; +} + /** The statements of the inner loop's body: pure declarations and * assignments, `if` statements over them, `acc = acc | C`, and * `if (acc === full) break` (collected into `breaks`). */ @@ -153,19 +167,12 @@ function innerStatements( innerStatements(s.else_ ?? [], locals, acc) ); } - if ( - s.kind === "assign" && - s.value.kind === "bin" && - s.value.op === "|" && - isRef(s.value.left, s.localId) - ) { - const bits = intLit(s.value.right); - if (bits !== null && bits >= 0 && bits <= 0x7fffffff) { - if (acc.id !== null && acc.id !== s.localId) return false; - acc.id = s.localId; - acc.bits |= bits; - return true; - } + const bits = orBits(s); + if (s.kind === "assign" && bits !== null) { + if (acc.id !== null && acc.id !== s.localId) return false; + acc.id = s.localId; + acc.bits |= bits; + return true; } return pureStatements([s], locals); }); @@ -263,6 +270,14 @@ function matchJam( // it only by its ORs. const accDecl = pre.find((s) => s.kind === "varDecl" && s.localId === accId); if (accDecl?.kind !== "varDecl" || intLit(accDecl.init) === null) return null; + const onlyOrs = everyStmtList(inner.body, { + expr: () => true, + stmt: (s) => writtenLocal(s) !== accId || orBits(s) !== null, + }); + if (!onlyOrs) return null; + // Every copy shares the inner counter: only the inner loop's update writes + // it. + if (writes(inner.body).has(j)) return null; // The counter, the inner bound and every value the inner loop reads from // outside the iteration are the same for every copy: only `pre` and the // counter vary. @@ -280,12 +295,16 @@ function matchJam( for (const id of postWrites) if (reads([...pre, inner], id) || own.has(id)) return null; for (const id of writes(pre)) if (!own.has(id)) return null; // After the inner loop, iterations past a copy's exit are dead: `post` - // reads no value the inner loop writes besides the accumulator, and the - // inner bound reads nothing the copies write. + // reads no value the inner loop writes besides the accumulator. for (const id of innerWrites) if (id !== accId && reads(post, id)) return null; - for (const id of own) - if (id !== j && reads([{ kind: "exprStmt", expr: inner.cond, loc: inner.loc }], id)) - return null; + // The copies share the inner loop's start and bound: they read neither the + // counter nor a local of the iteration. + const controls: IrStmt[] = [ + { kind: "exprStmt", expr: inner.init.init, loc: inner.loc }, + { kind: "exprStmt", expr: inner.cond, loc: inner.loc }, + ]; + if (reads(controls, counter)) return null; + for (const id of own) if (id !== j && reads(controls, id)) return null; return { counter, values, pre, inner, post, acc: accId, full }; } diff --git a/tests/corpus/saturating-loop-jams.ts b/tests/corpus/saturating-loop-jams.ts index fdc066eb8..fb0fd5bb3 100644 --- a/tests/corpus/saturating-loop-jams.ts +++ b/tests/corpus/saturating-loop-jams.ts @@ -66,6 +66,82 @@ function quads(initial: Float64Array, ci: number): number { return res; } +// Loops the jam keeps as they are. The inner loop clears the accumulator. +function resets(initial: Float64Array): 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 = 0; j < 30; 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; + } + if (j > 20) b = 0; + } + res = res * 4 + b; + } + return res; +} + +// The inner loop's body advances its counter. +function skips(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + let steps = 0; + for (let j = 0; j < 16; 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; + } + if (zr1 < 0.5) j = j + 1; + } + res = res * 4 + b; + } + return res; +} + +// The inner loop's start and bound depend on the outer iteration. +function shifted(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + const start = i * 3; + for (let j = start; j < 10 + i; 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; @@ -83,3 +159,6 @@ const q = new Float64Array([0.1, 1.5, -0.3, 3]); console.log(quads(q, 0.2), quads(q, -1.9), quads(new Float64Array(4), 0)); // A row past the end of the coordinates reads NaN, which never escapes. 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)); From 5f7a5dc8835af61f0be6fdb021f3ed524b65e69e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:10:05 -0500 Subject: [PATCH 41/47] Keep an async method's promise in non-promise slots; convert filtered arms beside an exact one - An async object-literal method whose contextual slot returns a non-promise (`() => void`) keeps its own promise return, and one whose slot returns a union takes only the union's promise arm (`Promise | void`): adopting the whole return type gave the method a non-promise type, refused with SC2001. - A type predicate whose record target is itself an arm of the element union keeps that arm's elements as they are and converts the other arms with its fields, instead of refusing the filter with SC1090. --- .../lowering/containers/filter-predicates.ts | 8 +++--- .../src/frontend/lowering/function-returns.ts | 26 +++++++++++++++++-- .../async-object-method-contextual-return.ts | 26 +++++++++++++++++++ tests/corpus/filter-predicate-model-narrow.ts | 10 +++++++ ...nc-object-method-contextual-return.ts.json | 2 +- .../filter-predicate-model-narrow.ts.json | 2 +- 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 18e6ac984..ba8fb9d53 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -277,10 +277,10 @@ function unionConversion( }); } // Every arm the target names must be one of the union's own arms, or a - // record target converts from at least one arm. - if (recordArmConversions > 0) { - if (kept !== 0) return null; - } else if (kept === 0 || kept !== targetArms.length) return null; + // record target converts from at least one arm. A record target that is + // itself an arm keeps that arm's elements as they are, and the arms with + // its fields convert. + if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null; body.push( typeError( `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 4ab067fc9..7378f26a7 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -154,6 +154,22 @@ function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration): return open; } +/** `type` when it is the ambient Promise, or its one Promise arm when it is + * a union with exactly one; undefined for anything else (`void`, `unknown`, + * a PromiseLike). */ +function promiseArm(lowerer: Lowerer, type: ts.Type): ts.Type | undefined { + const isPromise = (t: ts.Type): boolean => { + const symbol = t.getSymbol(); + return ( + symbol !== undefined && ts.symbolName(symbol) === "Promise" && lowerer.isStdlibSymbol(symbol) + ); + }; + if (isPromise(type)) return type; + if (!type.isUnionType()) return undefined; + const arms = ts.constituentTypes(type).filter(isPromise); + return arms.length === 1 ? arms[0] : undefined; +} + /** Map a declaration's return contract while retaining JavaScript * inference fallbacks. Diagnostics preserve the caller's blame node. */ export function declaredReturnType( @@ -188,8 +204,14 @@ export function declaredReturnType( slot === undefined ? [] : lowerer.checker.getCallSignatures(lowerer.checker.getTypeOfSymbol(slot)); - if (signatures.length === 1) - retTsType = lowerer.checker.getReturnTypeOfSignature(signatures[0]!); + // A slot whose return is not a promise (`() => void`, or a union such + // as `Promise | void`) contributes only its promise: an async + // method returns one whatever the slot says. + const promise = + signatures.length === 1 + ? promiseArm(lowerer, lowerer.checker.getReturnTypeOfSignature(signatures[0]!)) + : undefined; + if (promise !== undefined) retTsType = promise; } // An inferred null/undefined return in JS can read a mutable checked-value // field. Keep the actual value instead of restricting it to the initializer. diff --git a/tests/corpus/async-object-method-contextual-return.ts b/tests/corpus/async-object-method-contextual-return.ts index 223063079..6c3a93758 100644 --- a/tests/corpus/async-object-method-contextual-return.ts +++ b/tests/corpus/async-object-method-contextual-return.ts @@ -12,3 +12,29 @@ function define(): ToolDefinition<{ loaded: string[] }> { }; } define().execute("1", "x").then((r) => console.log(JSON.stringify(r))); + +// Slots whose return is not a promise: an async method there still returns +// its own promise, and a union slot contributes only its promise arm. +interface Hooks { + start(): void; + stop(): Promise | void; + load(q: string): Promise<{ n: number; q: string }> | undefined; +} +const hooks: Hooks = { + async start() { + console.log("start"); + }, + async stop() { + console.log("stop"); + }, + async load(q) { + return { n: q.length, q }; + }, +}; +async function run(): Promise { + await hooks.start(); + await hooks.stop(); + const loaded = await hooks.load("abc"); + console.log(loaded?.n, loaded?.q); +} +run(); diff --git a/tests/corpus/filter-predicate-model-narrow.ts b/tests/corpus/filter-predicate-model-narrow.ts index 4c97445d0..c881fbdef 100644 --- a/tests/corpus/filter-predicate-model-narrow.ts +++ b/tests/corpus/filter-predicate-model-narrow.ts @@ -41,4 +41,14 @@ const restored = restore(stored); for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model); console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0)); +// A target that is itself an arm of the union keeps that arm's elements as +// they are, while another arm with its fields converts. +type Mixed = Model<"a"> | Model | Image; +function pick(stored: Mixed[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} +const exact: Model<"a"> = { kind: "chat", api: "a", id: "5", window: 3, compat: { strict: false } }; +const picked = pick([exact, ...stored]); +console.log(picked.map((model) => `${model.id}:${model.window}:${model.compat?.strict}`).join(" "), picked[0] === exact); + export {}; diff --git a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json index b0f0df482..0b84e3c8a 100644 --- a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json +++ b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json @@ -1 +1 @@ -{ "plain": 3268, "san": 6404 } +{ "plain": 12154, "san": 118497 } diff --git a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json index 2440b7ddb..11d5632f1 100644 --- a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json +++ b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json @@ -1 +1 @@ -{ "plain": 4307, "san": 81017 } +{ "plain": 12080, "san": 11582 } From a35bf813db18d4f5abea1b0a4480f3a0bf007fb2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:23:07 -0500 Subject: [PATCH 42/47] Keep the pointer when a narrowing drops a pointer union's unit arm A union retype between two shape unions (or two class unions) shares the pointer when both encode their unit arms the same way. Narrowing a field read past `=== undefined` or `=== null` retypes a union with a unit arm to one without it, so the encodings differ and each listed arm was rebuilt: a retain, a tag switch, a second retain and a release, for the same pointer. The unit encodings only matter when the value may hold a unit arm. When the retype lists none of the source's unit tags, every value is a record or class pointer whose arm the target derives from the record or class itself, so the pointer is the target value. The rebuild's release also made the pointer a nursery cycle candidate, and in the native compiler those candidates kept the nursery bypass's probes of closures and boxes succeeding. Without them, a probe window can open while young cycles of the type are held by older garbage that the bypass promoted earlier. The window then frees nothing, the type stays bypassed, and the garbage waits for a mature pass, which on a cold tsc-ts build raised the compiler's peak RSS by about 100 MB. A nursery probe window that opens now makes the next release-triggered pass that would run at the nursery run at the mature level, which reclaims that garbage before the window is judged. --- .../compiler/src/backend/llvm/expr-records.ts | 21 +++--- .../src/backend/llvm/union-retype.test.ts | 48 +++++++++++++ packages/runtime/src/scr_cycle.c | 28 +++++++- packages/runtime/test/test_cycle.c | 70 +++++++++++++++++++ 4 files changed, 155 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index e65a356eb..9e3a5e9b0 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -61,9 +61,11 @@ export function emitUnionWiden( /** The identity union conversion (IR unionRetype). Two tagged unions * share the operand's box: its +1 becomes the result, so no box is built * and no payload is retained. The operand is a whole-value use in every - * lifetime analysis, so it is never a stack box. A nullable-pointer union - * on either side, or a source union whose boxes a checked-dynamic cast has - * cached (and refreshes in place), rebuilds each listed arm instead. */ + * lifetime analysis, so it is never a stack box. Two pointer unions of one + * encoding share the pointer the same way. A pointer union on only one + * side, pointer unions whose encodings differ for an arm the value may + * hold, or a source union whose boxes a checked-dynamic cast has cached (and + * refreshes in place), rebuild each listed arm instead. */ export function emitUnionRetype( host: LlvmEmitterContext, e: IrExpr & { kind: "unionRetype" }, @@ -75,14 +77,15 @@ export function emitUnionRetype( if (from && to) { // Equal encodings make the pointer itself the target value. A shape // union derives its record arms from the record and a class union its - // class arms from the class id, so only the unit encodings must agree. + // class arms from the class id, so only the unit encodings must agree, + // and only when the value may hold one of the source's unit arms: a + // narrowing that drops `undefined` or `null` keeps the very pointer. + const holdsUnit = e.tags.some((tag) => tag === from.unitTag || tag === from.nullTag); + const units = !holdsUnit || (from.unitTag === to.unitTag && from.nullTag === to.nullTag); const same = from.repr === "single" && to.repr === "single" - ? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag - : from.repr !== "single" && - from.repr === to.repr && - from.unitTag === to.unitTag && - from.nullTag === to.nullTag; + ? from.refTag === to.refTag && units + : from.repr !== "single" && from.repr === to.repr && units; if (same) return host.own({ name: value.name, type: e.type }); return host.own({ name: rebuildRetype(host, value.name, e), type: e.type }); } diff --git a/packages/compiler/src/backend/llvm/union-retype.test.ts b/packages/compiler/src/backend/llvm/union-retype.test.ts index 502940e71..bc82e7e44 100644 --- a/packages/compiler/src/backend/llvm/union-retype.test.ts +++ b/packages/compiler/src/backend/llvm/union-retype.test.ts @@ -268,3 +268,51 @@ test("record unions retype as the record pointer itself", async () => { // No record of the visitor is ever boxed. expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); }); + +// Children that may be absent, as IR nodes have: reading one after its +// `=== undefined` or `=== null` test narrows a shape union with a unit arm +// to the shape union without it. +const OPTIONAL_CHILDREN = ` +type Num = { kind: "num"; value: number }; +type Ref = { kind: "ref"; id: string }; +type Call = { kind: "call"; callee: Expr; receiver?: Expr; args: Expr[] }; +type Init = { kind: "init"; value: Expr | null }; +type Expr = Num | Ref | Call | Init; +function count(e: Expr): number { + switch (e.kind) { + case "num": + case "ref": + return 1; + case "call": + return ( + 1 + + count(e.callee) + + (e.receiver === undefined ? 0 : count(e.receiver)) + + e.args.reduce((n, a) => n + count(a), 0) + ); + case "init": + return 1 + (e.value === null ? 0 : count(e.value)); + } +} +const tree: Expr = { + kind: "call", + callee: { kind: "ref", id: "f" }, + receiver: { kind: "init", value: { kind: "num", value: 1 } }, + args: [{ kind: "init", value: null }], +}; +console.log(count(tree)); +`; + +test("dropping a shape union's unit arm keeps the record pointer", async () => { + const { module, ir } = await llvmFor(OPTIONAL_CHILDREN, false); + const identities = module.functions.filter( + (fn) => + fn.name.startsWith("%union.retag.") && + JSON.stringify(fn.body).includes('"kind":"unionRetype"'), + ); + expect(identities.length).toBeGreaterThan(0); + for (const helper of identities) { + const symbol = `sc_bf__x25_union_retag_${helper.name.slice("%union.retag.".length)}`; + expect(body(ir, symbol)).not.toMatch(/retype\.built/); + } +}); diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index a7157f9af..02ad86aa1 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -235,6 +235,7 @@ typedef struct { bool falling; bool idle_points; size_t death_due_live; + bool probe_due; bool keep_base; size_t trigger; size_t alloc_trigger; @@ -286,6 +287,7 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_falling (scr_cyc_thread.falling) #define scr_cyc_idle_points (scr_cyc_thread.idle_points) #define scr_cyc_death_due_live (scr_cyc_thread.death_due_live) +#define scr_cyc_probe_due (scr_cyc_thread.probe_due) #define scr_cyc_keep_base (scr_cyc_thread.keep_base) #define scr_cyc_kinds (scr_cyc_thread.kinds) #define scr_cyc_kinds_cap (scr_cyc_thread.kinds_cap) @@ -640,6 +642,10 @@ static SCR_TL bool scr_cyc_falling = false, scr_cyc_keep_base = false; #ifndef SCR_WORKERS static SCR_TL bool scr_cyc_idle_points = false; static SCR_TL size_t scr_cyc_death_due_live = 0; +/* A nursery probe window opened since the last scheduled pass (see "level + * bypass"): the next pass the release path would run at the nursery runs at + * the mature level instead. */ +static SCR_TL bool scr_cyc_probe_due = false; #endif /* The nursery trigger a program sets for itself @@ -976,9 +982,11 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { if (!by_growth && !by_death && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { gen = SCR_CYC_MATURE; by_growth = scr_cyc_grown(SCR_CYC_MATURE); - if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE)) + if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE) && + !scr_cyc_probe_due) gen = SCR_CYC_NURSERY; } + scr_cyc_probe_due = false; scr_cyc_scheduled_pass(gen, by_growth, by_death); } @@ -1059,6 +1067,16 @@ bool scr_cyc_collect_idle(void) { * a probe window every candidate of the type stays at the level, so the * cycles it builds during the window die there. * + * Those young cycles can still be held by older garbage: a dead structure + * the bypass promoted before the window that points at objects built in it. + * A restricted pass reads that edge as external, so the window frees none + * of them, judges the type long-lived again, and the garbage on both sides + * waits for a mature pass, which the growth schedule may not run until the + * heap has grown a multiple of its size. So a nursery window that opens + * makes the next release-triggered pass that would run at the nursery run + * at the mature level: it reclaims the promoted garbage once, and the window + * judges the young cycles on their own. + * * This changes only which generation an object is judged in. Any * generation assignment is sound (a restricted pass treats edges from older * objects as external), and a bypassing candidate is buffered like any @@ -1110,7 +1128,8 @@ static inline ScrCycKind *scr_cyc_kind(uint32_t kind) { /* Whether a candidate of this type at level `gen` (nursery or mature) * skips that level's passes (see above). The last candidate a bypass sends - * past the level opens a probe window, with fresh counts. */ + * past the level opens a probe window, with fresh counts, and a nursery + * window asks for a mature pass first. */ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { uint32_t kind = type->kind; @@ -1119,9 +1138,12 @@ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycT if (k->bypass == 0) return false; if (--k->bypass == 0) { k->walked = k->freed = 0; + if (gen == SCR_CYC_NURSERY) { + scr_cyc_probe_due = true; #if SCR_CYC_INLINE_BUFFERING - if (gen == SCR_CYC_NURSERY) scr_cyc_nursery_bypassing--; + scr_cyc_nursery_bypassing--; #endif + } } return true; } diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 961d43586..f18d29ff8 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -1039,6 +1039,75 @@ static void check_nursery_bypass(void) { scr_collect_cycles(); } +/* A nursery probe window that opens makes the next release-triggered pass + * that would run at the nursery run at the mature level, which reclaims + * dead promoted rings a nursery pass stops at. */ +static size_t window_kind_freed, window_ring_freed; +static void window_kind_free(void *obj) { + window_kind_freed++; + node_free(obj); +} +static void window_ring_free(void *obj) { + window_ring_freed++; + node_free(obj); +} +static ScrCycType window_kind_type = { &node_trace, &window_kind_free, 9 }; +static ScrCycType window_ring_type = { &node_trace, &window_ring_free, 0 }; + +/* A candidate of the window kind that dies once it has been routed, as the + * next pass would route it: it counts down a bypass without growing the + * heap. */ +static void release_window_candidate(void) { + Node *node = scr_cyc_alloc(sizeof(*node), &window_kind_type); + node->rc = 2; + release_live(node); + scr_cyc_route_bypassed_at(); + node->rc = 0; + scr_cyc_on_dead(node); + window_kind_free(node); +} + +static void check_probe_window_runs_mature_pass(void) { + /* Every release collects at a threshold of 1, and passes with only a few + * candidates walk too few of them to decide a window. */ + if (configured_nursery_threshold() < 8) return; + enum { WINDOW = 8192, INTERVAL = 16, LIVE = WINDOW, RING = 4 }; + Node **live = calloc(LIVE, sizeof(*live)); + if (!live) scr_trap("cycle test: out of memory\n"); + for (size_t i = 0; i < LIVE; i++) { + live[i] = scr_cyc_alloc(sizeof(*live[i]), &window_kind_type); + live[i]->rc = 1; + read_live(live[i]); + scr_cyc_pass_at(SCR_CYC_NURSERY); + } + scr_collect_cycles(); + /* A pass that an earlier window asked for runs now. */ + run_release_passes(); + + /* A dead ring promoted to the mature level: nursery passes stop at it. */ + Node *ring = make_ring_of(RING, &window_ring_type); + ring->rc++; + release_live(ring); + scr_cyc_pass_at(SCR_CYC_NURSERY); + release_live(ring); + run_release_passes(); + check(window_ring_freed == 0, "a nursery pass freed a dead mature ring"); + + /* The window kind's candidates go past the nursery until a probe window + * opens; the next release-triggered pass runs at the mature level. */ + for (size_t i = 0; i < (size_t)WINDOW * INTERVAL; i++) release_window_candidate(); + run_release_passes(); + check(window_ring_freed == RING, "the pass after a probe window opened missed a dead mature ring"); + + for (size_t i = 0; i < LIVE; i++) { + live[i]->rc = 0; + scr_cyc_on_dead(live[i]); + window_kind_free(live[i]); + } + free(live); + scr_collect_cycles(); +} + /* The mature level's bypass: a type whose mature candidates keep surviving * goes straight to the old generation, its dead cycles are found by full * passes, and once its objects die at the mature level again it is judged @@ -1719,6 +1788,7 @@ int main(void) { check_death_brings_full_pass(); check_nursery_bypass(); check_mature_bypass(); + check_probe_window_runs_mature_pass(); check_probe_sees_candidate_rings(); check_rebuffer_past_mature(); check_allocation_growth_runs_passes(); From f909bdf6c85d6cf26ed440bcd87e1c063d56b8b2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:34:34 -0500 Subject: [PATCH 43/47] Make fewer syscalls per fs thread-pool completion and pool-thread write - A worker writes the completion wake byte only when the pipe has none. The loop reads it, with the queue lock held, when it takes the last waiting completion. The pipe holds a byte exactly while completions wait, so a burst of completions costs one write and one read instead of one write per completion and a read loop per dispatch. - The fs pool's threads keep SIGPIPE and SIGXFSZ blocked for their lifetime. scr_fs_write_once checks once per thread whether both are blocked; on such a thread a write makes no signal-mask calls, and a write that fails with EPIPE or EFBIG consumes the signal it raised. Other threads keep the per-write guard. fs-write-sync.test.ts covers EPIPE and EFBIG from write streams, whose writes run on the pool's threads, and matches Node's errors. --- packages/runtime/src/scr_async.c | 42 +++++++++++++++++------- packages/runtime/src/scr_lib.c | 43 +++++++++++++++++++++++++ tests/fixtures/fs-write-sync-sigpipe.ts | 20 ++++++++++-- tests/harness/fs-write-sync.test.ts | 18 ++++++++--- 4 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0225269f4..92e95b8b7 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3423,20 +3423,25 @@ ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath) { * platform mutex publishes each result before the loop removes it from the * completion queue. */ #if !defined(SCR_LIB) && !defined(__wasi__) -/* The completion wake: a worker signals it once per published completion, +/* The completion wake: a worker signals it when it publishes a completion, * so the loop's sleep ends for the completion — a pipe the poll(2) sleep - * watches on POSIX, an auto-reset event the Windows sleep waits on. */ + * watches on POSIX, an auto-reset event the Windows sleep waits on. On + * POSIX, `posted` (guarded by the queue lock) records that the pipe holds + * a byte: a worker writes one only when none is there, and the loop reads + * it when it takes the last completion, so the pipe holds a byte exactly + * while completions wait. */ typedef struct ScrFsRenameWake { #ifdef _WIN32 HANDLE event; /* NULL until created */ #else int fds[2]; /* -1 until created */ + bool posted; #endif } ScrFsRenameWake; #ifdef _WIN32 #define SCR_FS_RENAME_WAKE_NONE {NULL} #else -#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}} +#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}, false} #endif #ifdef SCR_WORKERS typedef struct ScrFsRenameOwner ScrFsRenameOwner; @@ -3530,28 +3535,29 @@ static void scr_fs_rename_wake_open(ScrFsRenameWake *wake) { #endif } +/* Called with the queue lock held. */ static void scr_fs_rename_wake_post(ScrFsRenameWake *wake) { #ifdef _WIN32 if (wake->event != NULL) (void)SetEvent(wake->event); #else - if (wake->fds[1] >= 0) { + if (wake->fds[1] >= 0 && !wake->posted) { char byte = 1; - ssize_t n = write(wake->fds[1], &byte, 1); /* a full pipe already wakes the loop */ - (void)n; + wake->posted = write(wake->fds[1], &byte, 1) == 1; } #endif } -/* POSIX: empty the pipe before taking the queue, so a completion published - * afterwards leaves its byte for the next poll. The Windows event resets - * when a wait consumes it. */ +/* POSIX, with the queue lock held: the loop took the last completion, so + * the pipe's byte is read and the next completion writes a new one. The + * Windows event resets when a wait consumes it. */ static void scr_fs_rename_wake_drain(ScrFsRenameWake *wake) { #ifdef _WIN32 (void)wake; #else - if (wake->fds[0] < 0) return; + if (wake->fds[0] < 0 || !wake->posted) return; + wake->posted = false; char buf[64]; - while (read(wake->fds[0], buf, sizeof buf) > 0) { + while (read(wake->fds[0], buf, sizeof buf) == (ssize_t)sizeof buf) { } #endif } @@ -3661,6 +3667,18 @@ 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). */ + sigset_t write_set; + sigemptyset(&write_set); +#ifdef SIGPIPE + sigaddset(&write_set, SIGPIPE); +#endif +#ifdef SIGXFSZ + sigaddset(&write_set, SIGXFSZ); +#endif + (void)pthread_sigmask(SIG_BLOCK, &write_set, NULL); scr_fs_rename_worker_loop(); return NULL; } @@ -3759,13 +3777,13 @@ static bool scr_fs_renames_dispatch(void) { #ifdef SCR_WORKERS if (!scr_fs_rename_owner) return false; #endif - scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner)); scr_fs_rename_lock_enter(); ScrFsRenameOp *op = scr_fs_rename_done; if (op != NULL) { scr_fs_rename_done = op->next; if (scr_fs_rename_done == NULL) scr_fs_rename_done_tail = &scr_fs_rename_done; } + if (scr_fs_rename_done == NULL) scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner)); scr_fs_rename_lock_leave(); if (op == NULL) return false; scr_fs_rename_pending_count--; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index ad0c613ee..edc2849c7 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4101,6 +4101,49 @@ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, #ifdef SIGXFSZ sigaddset(&write_set, SIGXFSZ); #endif + /* A thread that keeps both signals blocked (the fs pool's threads) writes + * without changing its mask; a write that fails with EPIPE or EFBIG then + * consumes the signal it left pending. The answer is per thread and + * computed once. */ + static SCR_TL signed char thread_blocks = -1; + if (thread_blocks < 0) { + sigset_t current; + bool blocks = pthread_sigmask(SIG_BLOCK, NULL, ¤t) == 0; +#ifdef SIGPIPE + blocks = blocks && sigismember(¤t, SIGPIPE) == 1; +#endif +#ifdef SIGXFSZ + blocks = blocks && sigismember(¤t, SIGXFSZ) == 1; +#endif + thread_blocks = blocks ? 1 : 0; + } + if (thread_blocks == 1) { + ssize_t n = positioned + ? scr_fs_pwrite(fd, data, length, position) + : write(fd, data, length); + if (n < 0) { + int write_errno = errno; + int raised = 0; +#ifdef SIGPIPE + if (write_errno == EPIPE) raised = SIGPIPE; +#endif +#ifdef SIGXFSZ + if (write_errno == EFBIG) raised = SIGXFSZ; +#endif + if (raised != 0 && sigpending(&pending) == 0 && sigismember(&pending, raised) == 1) { + sigset_t raised_set; + sigemptyset(&raised_set); + sigaddset(&raised_set, raised); + int caught = 0; + int wait_err; + do { + wait_err = sigwait(&raised_set, &caught); + } while (wait_err == EINTR); + } + errno = write_errno; + } + return n; + } if (pthread_sigmask(SIG_BLOCK, &write_set, &old_set) != 0) { return positioned ? scr_fs_pwrite(fd, data, length, position) diff --git a/tests/fixtures/fs-write-sync-sigpipe.ts b/tests/fixtures/fs-write-sync-sigpipe.ts index e5fca1d71..1cbafb8b3 100644 --- a/tests/fixtures/fs-write-sync-sigpipe.ts +++ b/tests/fixtures/fs-write-sync-sigpipe.ts @@ -5,12 +5,28 @@ import * as os from "node:os"; import * as path from "node:path"; const mode = process.argv.length > 2 ? process.argv[2] : "sigpipe"; -if (mode === "sigpipe") { +if (mode === "sigpipe" || mode === "stream-sigpipe") { const sleeper = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sleeper, 0, 0, 100); } -if (mode === "eagain") { +if (mode === "stream-sigpipe") { + // a write stream's writes run on the fs pool's threads + const out = fs.createWriteStream("", { fd: 1 }); + out.on("error", (err: NodeJS.ErrnoException) => { + console.error("stream caught:", err.name, err.code, err.message); + }); + out.write("probe\n"); +} else if (mode === "stream-efbig") { + const scratch = path.join(os.tmpdir(), `scr-write-stream-${process.pid}`); + const out = fs.createWriteStream(scratch); + out.on("error", (err: NodeJS.ErrnoException) => { + console.error("stream caught:", err.name, err.code, err.message); + fs.unlinkSync(scratch); + }); + out.write(Buffer.alloc(512)); + out.write(Buffer.alloc(4096)); +} else if (mode === "eagain") { const data = Buffer.alloc(65536); for (;;) { try { diff --git a/tests/harness/fs-write-sync.test.ts b/tests/harness/fs-write-sync.test.ts index bd277cd1c..a9ed08d98 100644 --- a/tests/harness/fs-write-sync.test.ts +++ b/tests/harness/fs-write-sync.test.ts @@ -1,7 +1,9 @@ -/* fs.writeSync contracts that need harness control over the native process's - * descriptors. The ordinary byte/error behavior stays in differential corpus - * 2685; this test controls stdout's pipe state, nonblocking mode, and the - * process file-size limit to pin the signal-backed descriptor errors. */ +/* fs.writeSync and write-stream contracts that need harness control over the + * native process's descriptors. The ordinary byte/error behavior stays in + * differential corpus 2685; this test controls stdout's pipe state, + * nonblocking mode, and the process file-size limit to pin the signal-backed + * descriptor errors, on the calling thread (writeSync) and on the fs pool's + * threads (write streams). */ import { execFileSync, spawn } from "node:child_process"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -80,6 +82,14 @@ describe.skipIf(process.platform === "win32")( "positioned first: 512\n" + "positioned caught: Error EFBIG EFBIG: file too large, write\n", ); + + const streamPipe = await run(result.binaryPath, ["stream-sigpipe"], true); + expect(streamPipe.outcome).toEqual({ code: 0, signal: null }); + expect(streamPipe.stderr).toBe("stream caught: Error EPIPE EPIPE: broken pipe, write\n"); + + const streamLimit = await run(launcher, ["rlimit", result.binaryPath, "stream-efbig"]); + expect(streamLimit.outcome).toEqual({ code: 0, signal: null }); + expect(streamLimit.stderr).toBe("stream caught: Error EFBIG EFBIG: file too large, write\n"); }); }, ); From 26e458932eeec0379db9b40730b2a37cb8b5f73c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 23:39:17 -0500 Subject: [PATCH 44/47] Poll the checker server's socket before a reply read blocks The native compiler sends most checker requests one at a time and blocks in recv for each reply. The server answers most of them in a few microseconds, and a reader the reply wakes takes tens of microseconds more per round trip, over about 130,000 round trips on a large program. scriptc_ts7_read now polls the socket with MSG_DONTWAIT for up to 200 us before it blocks. Windows keeps its blocking pipe read. --- .../compiler/native/ts7-process-child.test.c | 13 ++++++- packages/compiler/native/ts7-process.c | 38 ++++++++++++++++++- packages/compiler/native/ts7-process.test.c | 15 ++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/compiler/native/ts7-process-child.test.c b/packages/compiler/native/ts7-process-child.test.c index d387f735a..c89fcbafb 100644 --- a/packages/compiler/native/ts7-process-child.test.c +++ b/packages/compiler/native/ts7-process-child.test.c @@ -13,6 +13,7 @@ #include #else #include +#include #include #include #include @@ -102,7 +103,9 @@ static int child_main(int argc, char **argv) { #endif } } - if (strcmp(mode, "echo") != 0) return 73; + // "slow-echo" answers each read 20 ms later: after the client's poll. + int slow = strcmp(mode, "slow-echo") == 0; + if (!slow && strcmp(mode, "echo") != 0) return 73; // Unbuffered reads let short writes make progress without waiting for a // complete stdio buffer. Fragment each echoed reply independently. uint8_t bytes[17]; @@ -113,6 +116,14 @@ static int child_main(int argc, char **argv) { ssize_t count = read(STDIN_FILENO, bytes, sizeof(bytes)); #endif if (count <= 0) return count < 0 ? 74 : 0; + if (slow) { +#ifdef _WIN32 + Sleep(20); +#else + struct timespec delay = {0, 20000000L}; + nanosleep(&delay, NULL); +#endif + } if (fwrite(bytes, 1, (size_t)count, stdout) != (size_t)count || fflush(stdout) != 0) return 75; } } diff --git a/packages/compiler/native/ts7-process.c b/packages/compiler/native/ts7-process.c index bf486c87d..e570fa98c 100644 --- a/packages/compiler/native/ts7-process.c +++ b/packages/compiler/native/ts7-process.c @@ -552,6 +552,41 @@ static int valid_range(const uint8_t *bytes, size_t size, uint32_t offset, uint3 return 1; } +#ifndef _WIN32 +/* How long a read polls the socket before it blocks. The server answers + * most requests within a few microseconds; a blocked reader that the reply + * wakes takes tens of microseconds more per round trip. */ +#define TS7_REPLY_POLL_NS 200000L + +static long monotonic_ns(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return now.tv_sec * 1000000000L + now.tv_nsec; +} + +/* recv on the server's socket: polls it for TS7_REPLY_POLL_NS, then waits. */ +static ssize_t ts7_recv(int socket, uint8_t *bytes, size_t length) { + long started = -1; + for (;;) { + ssize_t count = recv(socket, bytes, length, MSG_DONTWAIT); + if (count >= 0) return count; + if (errno == EINTR) continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) return -1; + long now = monotonic_ns(); + if (started < 0) started = now; + else if (now - started >= TS7_REPLY_POLL_NS) break; +#if defined(__x86_64__) || defined(__i386__) + __builtin_ia32_pause(); +#elif defined(__aarch64__) + __asm__ volatile("yield"); +#endif + } + ssize_t count; + do { count = recv(socket, bytes, length, 0); } while (count < 0 && errno == EINTR); + return count; +} +#endif + int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, uint32_t offset, uint32_t length) { Ts7Process *p = find_process(handle); @@ -565,8 +600,7 @@ int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, if (code == ERROR_BROKEN_PIPE) return 0; system_error(code); return -1; #else - ssize_t count; - do { count = recv(p->socket, bytes + offset, length, 0); } while (count < 0 && errno == EINTR); + ssize_t count = ts7_recv(p->socket, bytes + offset, length); if (count < 0) { system_error(errno); return -1; } return (int32_t)count; #endif diff --git a/packages/compiler/native/ts7-process.test.c b/packages/compiler/native/ts7-process.test.c index e07a7d10c..c51541772 100644 --- a/packages/compiler/native/ts7-process.test.c +++ b/packages/compiler/native/ts7-process.test.c @@ -149,6 +149,20 @@ static void exchange_cases(void) { scriptc_ts7_close(newer); } +/* A reply that arrives after the read's poll window: the read waits for it. */ +static void slow_reply_cases(void) { + uint32_t id = start("slow-echo", 0); + assert(id != 0); + const uint8_t bytes[3] = {1, 2, 3}; + uint8_t result[3] = {0}; + for (unsigned round = 0; round < 3; round++) { + write_exact(id, bytes, sizeof(bytes), 0, sizeof(bytes)); + read_exact(id, result, sizeof(result)); + assert(memcmp(bytes, result, sizeof(bytes)) == 0); + } + scriptc_ts7_close(id); +} + static void failure_cases(void) { uint8_t bytes[1] = {42}; const char *saved = executable; @@ -289,6 +303,7 @@ static int process_main(int argc, char **argv) { unsigned before = resource_count(); argument_cases(); exchange_cases(); + slow_reply_cases(); failure_cases(); #ifndef _WIN32 attach_cases(); From 50aa42883f303e53343ac177211f0068a00e7a44 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 00:18:11 -0500 Subject: [PATCH 45/47] Cache the pool-thread signal check per OS thread scr_fs_write_once cached whether the calling thread blocks SIGPIPE and SIGXFSZ in an SCR_TL variable. SCR_TL is empty in builds without thread instances, so a pool thread's answer applied to the main thread too, and a later writeSync to a closed pipe ended the process with SIGPIPE instead of throwing EPIPE. The cache is _Thread_local now. The fs-write-sync fixture covers a write stream followed by writeSync on a closed stdout. --- packages/runtime/src/scr_lib.c | 7 ++++--- tests/fixtures/fs-write-sync-sigpipe.ts | 16 +++++++++++++++- tests/harness/fs-write-sync.test.ts | 4 ++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index edc2849c7..9ef71a223 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4103,9 +4103,10 @@ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, #endif /* A thread that keeps both signals blocked (the fs pool's threads) writes * without changing its mask; a write that fails with EPIPE or EFBIG then - * consumes the signal it left pending. The answer is per thread and - * computed once. */ - static SCR_TL signed char thread_blocks = -1; + * consumes the signal it left pending. The answer is per OS thread and + * computed once: _Thread_local, not SCR_TL, which is empty in builds + * without thread instances while the pool threads still exist. */ + static _Thread_local signed char thread_blocks = -1; if (thread_blocks < 0) { sigset_t current; bool blocks = pthread_sigmask(SIG_BLOCK, NULL, ¤t) == 0; diff --git a/tests/fixtures/fs-write-sync-sigpipe.ts b/tests/fixtures/fs-write-sync-sigpipe.ts index 1cbafb8b3..24c0012b4 100644 --- a/tests/fixtures/fs-write-sync-sigpipe.ts +++ b/tests/fixtures/fs-write-sync-sigpipe.ts @@ -5,7 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; const mode = process.argv.length > 2 ? process.argv[2] : "sigpipe"; -if (mode === "sigpipe" || mode === "stream-sigpipe") { +if (mode === "sigpipe" || mode === "stream-sigpipe" || mode === "stream-then-sync-sigpipe") { const sleeper = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sleeper, 0, 0, 100); } @@ -17,6 +17,20 @@ if (mode === "stream-sigpipe") { console.error("stream caught:", err.name, err.code, err.message); }); out.write("probe\n"); +} else if (mode === "stream-then-sync-sigpipe") { + // a pool thread writes first; the calling thread's writeSync after it + // keeps its own guard + const scratch = path.join(os.tmpdir(), `scr-write-then-sync-${process.pid}`); + const file = fs.createWriteStream(scratch); + file.end("pool\n", () => { + fs.unlinkSync(scratch); + try { + fs.writeSync(1, "probe\n"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.error("sync caught:", err.name, err.code); + } + }); } else if (mode === "stream-efbig") { const scratch = path.join(os.tmpdir(), `scr-write-stream-${process.pid}`); const out = fs.createWriteStream(scratch); diff --git a/tests/harness/fs-write-sync.test.ts b/tests/harness/fs-write-sync.test.ts index a9ed08d98..1c8b6c41e 100644 --- a/tests/harness/fs-write-sync.test.ts +++ b/tests/harness/fs-write-sync.test.ts @@ -87,6 +87,10 @@ describe.skipIf(process.platform === "win32")( expect(streamPipe.outcome).toEqual({ code: 0, signal: null }); expect(streamPipe.stderr).toBe("stream caught: Error EPIPE EPIPE: broken pipe, write\n"); + const thenSync = await run(result.binaryPath, ["stream-then-sync-sigpipe"], true); + expect(thenSync.outcome).toEqual({ code: 0, signal: null }); + expect(thenSync.stderr).toBe("sync caught: Error EPIPE\n"); + const streamLimit = await run(launcher, ["rlimit", result.binaryPath, "stream-efbig"]); expect(streamLimit.outcome).toEqual({ code: 0, signal: null }); expect(streamLimit.stderr).toBe("stream caught: Error EFBIG EFBIG: file too large, write\n"); From 10479ce2e0bdcd9033e97c9a0816c7524f6f9581 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 01:01:21 -0500 Subject: [PATCH 46/47] Link the scriptc CLI without huge text An executable with at least 4 MiB of program code is linked with 2 MiB segment alignment and published with one write, so the page cache holds its text in 2 MiB folios and the runtime advises huge pages for it. A run then maps every 2 MiB text window it touches whole. Most runs of the compiler are short: `scriptc --version` peaked at 76 MB and an unchanged rebuild at 78 MB, against 20 MB and 26 MB with 4 KiB alignment, and cold and edited builds of tsc-ts took the same time either way. compile() takes an internal `hugeText: false`, which links with 4 KiB alignment whatever the code size and is part of the executable cache key. scripts/build-native-cli.mts passes it. Executables the compiler builds keep huge text. --- packages/compiler/src/backend/link-plan.ts | 12 ++++---- .../compiler/src/backend/runtime-pack.test.ts | 29 +++++++++++++++++++ packages/compiler/src/compile-types.ts | 6 ++++ .../src/executable/executable-cache.ts | 4 +++ packages/compiler/src/index.ts | 5 ++++ scripts/build-native-cli.mts | 6 ++++ 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts index 19cff2d5f..c59095a98 100644 --- a/packages/compiler/src/backend/link-plan.ts +++ b/packages/compiler/src/backend/link-plan.ts @@ -48,6 +48,8 @@ export async function createNativeLinkPlan(options: { windowsSubsystem?: WindowsSubsystem; profileGenerate?: boolean; profileLayout?: boolean; + /** `false` keeps 4 KiB segment alignment for large program code. */ + hugeText?: boolean; programObjectDependencies?: readonly NativeArtifactDependency[]; env?: NodeJS.ProcessEnv; resolver?: (specifier: string) => string; @@ -55,6 +57,9 @@ export async function createNativeLinkPlan(options: { if (options.ffi?.frameworks?.length && options.target.platform !== "darwin") throw new Error("FFI frameworks require a Darwin target"); const runtimePack = await loadRuntimePack(options); + const hugeText = + options.hugeText !== false && + hugeTextProgram(options.target, [options.programObject, ...(options.programPartitions ?? [])]); return { target: options.target, darwinDebugSymbols: needsDarwinDebugSymbols( @@ -88,11 +93,6 @@ export async function createNativeLinkPlan(options: { programObjectDependencies: [...(options.programObjectDependencies ?? [])], runtimePack, ...(options.staticLink === true ? { staticLink: true } : {}), - ...(hugeTextProgram(options.target, [ - options.programObject, - ...(options.programPartitions ?? []), - ]) - ? { hugeText: true } - : {}), + ...(hugeText ? { hugeText: true } : {}), }; } diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index a12c137b2..457f96749 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -14,6 +14,7 @@ import { RuntimePackError, type RuntimePackManifest, } from "./runtime-pack.js"; +import { HUGE_TEXT_MIN_CODE_BYTES } from "./huge-text.js"; import { createNativeLinkPlan } from "./link-plan.js"; import { executableLinkerEnvironmentFingerprint, @@ -266,6 +267,34 @@ describe("runtime pack manifests", () => { expect(staticPlan.staticLink).toBe(true); }); + test("Linux links of large program code use huge text unless it is turned off", async () => { + const { packagePath, root } = await fixture(LINUX_X64_GNU_TARGET); + // An ELF64 relocatable object's headers with one executable section of + // HUGE_TEXT_MIN_CODE_BYTES; only the headers are read. + const object = Buffer.alloc(64 + 2 * 64); + object.writeUInt32BE(0x7f454c46, 0); + object[4] = 2; + object[5] = 1; + object.writeBigUInt64LE(64n, 40); + object.writeUInt16LE(64, 58); + object.writeUInt16LE(2, 60); + object.writeUInt32LE(1, 128 + 4); // SHT_PROGBITS + object.writeBigUInt64LE(0x6n, 128 + 8); // SHF_ALLOC | SHF_EXECINSTR + object.writeBigUInt64LE(BigInt(HUGE_TEXT_MIN_CODE_BYTES), 128 + 32); + await writeFile(join(root, "program.o"), object); + const options = { + target: LINUX_X64_GNU_TARGET, + programObject: join(root, "program.o"), + outPath: join(root, "program"), + features: BASE, + ffi: null, + optimization: "release" as const, + resolver: () => packagePath, + }; + expect((await createNativeLinkPlan(options)).hugeText).toBe(true); + expect((await createNativeLinkPlan({ ...options, hugeText: false })).hugeText).toBeUndefined(); + }); + test("selection chooses the most-specific variant and feature archive", async () => { const { packagePath } = await fixture(); const resolver = () => packagePath; diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 5a382d9c6..404648051 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -57,6 +57,12 @@ export interface CompileBaseOptions { * at startup when the system allows them. The moved code stays resident. * Executables only; no effect on other platforms. */ largePages?: boolean; + /** Internal: `false` links a Linux executable with 4 KiB segment alignment + * even when its program code is large enough for text on huge pages + * (backend/huge-text.ts). The scriptc CLI is linked this way + * (scripts/build-native-cli.mts): most of its runs are short, and a short + * run maps every 2 MiB text window it touches whole. */ + hugeText?: false; /** How often the program throws (`--exceptions`). `rare` puts recursive * functions and those near a `throw` on the unwinding exception ABI too: * calls to them need no pending-flag test, and an exception that crosses diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 91fa7d791..a6dcc69d8 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -172,6 +172,8 @@ export interface EarlyExecutableCacheOptions { libnodeIdentity?: () => string | undefined; /** Omitted unless --large-pages is set. */ largePages?: true; + /** Omitted unless the build links without huge text (CompileOptions.hugeText). */ + noHugeText?: true; /** Omitted unless --exceptions=rare is set. */ exceptions?: "rare"; /** Omitted for the x86-64 baseline (`--target-cpu`). */ @@ -343,6 +345,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), ...(options.largePages ? ["large-pages"] : []), + ...(options.noHugeText ? ["no-huge-text"] : []), ...(options.exceptions === "rare" ? ["exceptions-rare"] : []), ...targetCpuKeyParts(options.targetCpu), ...cpuDispatchKeyParts(options.cpuDispatch), @@ -393,6 +396,7 @@ function routeKey(options: Omit { const programIsObject = /\.(?:o|obj)$/.test(llvmPath); const runtimePackTarget = programIsObject && !sanitize ? nativeCodegenTarget() : null; @@ -650,6 +651,7 @@ async function compileExecutableNative( ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), ...(profileGenerate ? { profileGenerate: true } : {}), ...(profileLayout ? { profileLayout: true } : {}), + ...(hugeText ? {} : { hugeText: false }), programObjectDependencies, }); const cacheableLinker = @@ -1823,6 +1825,7 @@ async function compileTracked( // identity (node-islands/libnode.ts) and a hit requires it unchanged. libnodeIdentity: () => libnodeCacheKey(opts.libnode, process.env, dirname(entryPath)), ...(opts.largePages === true ? { largePages: true as const } : {}), + ...(opts.hugeText === false ? { noHugeText: true as const } : {}), ...(opts.exceptions === "rare" ? { exceptions: "rare" as const } : {}), ...targetCpuField(opts.targetCpu), ...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}), @@ -2003,6 +2006,7 @@ async function compileTracked( profile?.generate !== undefined, hitSources.llvmPath, nativeProfileLayout(profile), + opts.hugeText !== false, ); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath); @@ -2151,6 +2155,7 @@ async function compileTracked( profile?.generate !== undefined, llvmPath, nativeProfileLayout(profile), + opts.hugeText !== false, ); timing("native-link"); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index 6a9334e4a..6c17b829b 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -164,6 +164,12 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"), ...(profileGenerate === undefined ? {} : { profileGenerate }), ...(profileUse === undefined ? {} : { profileUse }), ...(functionLayout === undefined ? {} : { functionLayout }), + // Most of the compiler's runs are short (an unchanged rebuild takes tens of + // milliseconds), and a run maps every 2 MiB text window it touches whole. Text + // on 4 KiB pages keeps an unchanged rebuild at about a third of the peak RSS, + // and builds of tsc-ts take the same time. Executables the compiler builds + // keep huge text. + hugeText: false, emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1", }); if (!result.ok) From a2e12517592070e1914444b72c2fcb406a188dcd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 06:02:00 -0500 Subject: [PATCH 47/47] Prepare v0.4.6 release --- CHANGELOG.md | 42 ++++++++++++++++++- packages/cli-darwin-arm64/package.json | 2 +- packages/cli-darwin-x64/package.json | 2 +- packages/cli-linux-arm64-gnu/package.json | 2 +- packages/cli-linux-arm64-musl/package.json | 2 +- packages/cli-linux-x64-gnu/package.json | 2 +- packages/cli-linux-x64-musl/package.json | 2 +- packages/cli-win32-x64-msvc/package.json | 2 +- packages/cli/package.json | 2 +- packages/compiler/package.json | 2 +- packages/compiler/surface-manifest.json | 2 +- packages/experimental/package.json | 2 +- packages/libnode-darwin-arm64/package.json | 2 +- packages/libnode-darwin-x64/package.json | 2 +- packages/libnode-linux-arm64-gnu/package.json | 2 +- packages/libnode-linux-x64-gnu/package.json | 2 +- packages/llvm-darwin-arm64/package.json | 2 +- packages/llvm-darwin-x64/package.json | 2 +- packages/llvm-linux-arm64-gnu/package.json | 2 +- packages/llvm-linux-arm64-musl/package.json | 2 +- packages/llvm-linux-x64-gnu/package.json | 2 +- packages/llvm-linux-x64-musl/package.json | 2 +- packages/llvm-win32-x64-msvc/package.json | 2 +- packages/node/package.json | 2 +- packages/runtime-android-arm64/package.json | 2 +- packages/runtime-darwin-arm64/package.json | 2 +- packages/runtime-darwin-x64/package.json | 2 +- packages/runtime-ios-arm64/package.json | 2 +- .../runtime-ios-simulator-arm64/package.json | 2 +- packages/runtime-linux-arm64-gnu/package.json | 2 +- .../runtime-linux-arm64-musl/package.json | 2 +- packages/runtime-linux-x64-gnu/package.json | 2 +- packages/runtime-linux-x64-musl/package.json | 2 +- packages/runtime-wasm32-wasi/package.json | 2 +- packages/runtime-win32-x64-msvc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/threads/package.json | 2 +- packages/types/package.json | 2 +- 38 files changed, 77 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ab725f0..8116c5d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ All notable changes to scriptc will be documented in this file. +## 0.4.6 + +### Highlights + +- **Node islands:** programs can load Node.js in-process on first use and run npm packages that scriptc doesn't compile in Node islands. + - Native values, closures and promises pass into islands by reference, and island handles, classes and collections can be used from compiled code. + - Modules are shared between a program and its islands, and island modules are evaluated only when the program uses them. + - Once Node loads, the runtime's event loop runs on Node's libuv, and programs exit cleanly with Node loaded. +- **New options:** + - `--exceptions=rare` unwinds through recursive functions instead of checking a status after each call. + - `--static` builds static Linux executables (opt-in). + - `--large-pages` moves executable code onto huge pages; `--cpu-dispatch` adds x86-64-v3 copies of hot functions. + - Type patches replace a package's declaration files or supply missing declarations. + - The shape overlay types JavaScript from facts the compiler proves (opt-in). +- **Faster programs:** + - Exceptions raise through cached unwind plans on x86-64 Linux, and every native function has an unwind table entry. + - Ordinary objects are stored as a layout plus value slots; unions of classes are the instance pointer itself; class instances that stay in their frame use frame storage. + - Object groups that die together are allocated in arenas, and the cycle collector does cheaper trial deletion and adapts its nursery trigger. + - The speed runtime is optimized with a committed runtime profile, and Math functions are computed as V8 does, with libm linked only when needed. + - Loops: short constant-count loops run together, for and while loops are versioned on the ranges their locals enter with, counted loops unroll by the period of a guard, and small leaf functions called from loops are inlined. + - Typed arrays: header fields are read once at a speculative loop's entry, products under bitwise operators stay in 32 bits, and shared typed-array elements are read and written with plain accesses at a counted loop's counter. + - Strings: UTF-16 indexes of long non-ASCII strings map in constant time, ASCII case converts eight bytes at a time, template literals allocate at their exact length, and strings iterate over their bytes. + - Untyped code: class instance fields are read inline at dynamic property sites with two inline-cache entries, native arrays iterate without type-name scans, and numbers pass to directly called untyped functions as JavaScript values. + - I/O: fs thread-pool completions and pool-thread writes make fewer syscalls; sockets, clients and agent entries register in constant time; fetch() reuses keep-alive connections; web streams, WritableStream and TransformStream run natively. + - Large executables map their text with 2 MiB pages, and freed heap pages are returned to the system after the frontend is disposed. +- **Faster builds:** + - Small release and speed programs compile in balanced partitions, the entry module's top-level code gets its own partition, and unchanged partitions stay cached across edits. + - Optimized executables that stay one partition generate code concurrently, and emission's workers stay busy. + - The native compiler is laid out by context-sensitive profiles collected in two phases. + - Type checking uses a shared tsc-ts server by default on Linux and macOS, and lib diagnostics are reused across options and module sets that cannot change them. + - Rebuilds skip the helper version probe and the digests of unchanged files, and the CLI links without huge text pages, so cold builds use less memory. +- **Node.js parity:** + - `Error.captureStackTrace`, Node's navigator, builtin globals read through the global object, and unhandled rejections reported in programs without async work. + - node:zlib is built on the zlib Node bundles; `Response.clone` and `Request.clone` work on typed values; fetch bodies have the members of a web ReadableStream; redirects whose chunked body has not ended are followed. + - Classes that extend Array compile; strings spread into their code points; frozen checked records stay frozen; sloppy-mode `this` is the global object. + - `util.format` runtime format strings, `inspect.custom` and colors, and untyped `promisify`. +- **TypeScript:** scriptc reads TypeScript 7.1's binary AST format and API server beside 7.0's, and tsc-ts is updated to 0.0.10. + + + ## 0.4.5 ### Highlights @@ -40,8 +80,6 @@ All notable changes to scriptc will be documented in this file. - `util.inspect` of dynamic classes, functions and `%o` arguments, and `Symbol.toStringTag` fields and getters, match Node. - **npm packages:** more npm and CommonJS dependencies compile statically, including namespace-style packages with guarded declarations and `var` exports, and untyped Maps, Sets and arrays are typed from their uses. - - ## 0.4.4 ### Highlights diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json index 1d4921f48..ad846da82 100644 --- a/packages/cli-darwin-arm64/package.json +++ b/packages/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for darwin-arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json index dca236b32..7ecacacd1 100644 --- a/packages/cli-darwin-x64/package.json +++ b/packages/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for darwin-x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json index 1459caa1d..b74fffebe 100644 --- a/packages/cli-linux-arm64-gnu/package.json +++ b/packages/cli-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-arm64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json index 3703c0059..a5a49e9ba 100644 --- a/packages/cli-linux-arm64-musl/package.json +++ b/packages/cli-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-arm64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json index 6d70361f9..4cc72ac73 100644 --- a/packages/cli-linux-x64-gnu/package.json +++ b/packages/cli-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-x64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json index 0c12fc60d..14db344e4 100644 --- a/packages/cli-linux-x64-musl/package.json +++ b/packages/cli-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-x64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json index 8872d2f15..827b76ad1 100644 --- a/packages/cli-win32-x64-msvc/package.json +++ b/packages/cli-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for win32-x64-msvc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 59f9db90a..0ab88f222 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "scriptc", - "version": "0.4.5", + "version": "0.4.6", "description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 3237def0a..a6b52a42f 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/compiler", - "version": "0.4.5", + "version": "0.4.6", "description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..7139fed28 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "compilerVersion": "0.4.5", + "compilerVersion": "0.4.6", "coverage": [ "Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.", "Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.", diff --git a/packages/experimental/package.json b/packages/experimental/package.json index 74e213448..55004c082 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/experimental", - "version": "0.4.5", + "version": "0.4.6", "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects, intern tables shared between threads, per-thread slots on shared objects, and array fields kept in their instances", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/libnode-darwin-arm64/package.json b/packages/libnode-darwin-arm64/package.json index 22d1e1889..9e9113a68 100644 --- a/packages/libnode-darwin-arm64/package.json +++ b/packages/libnode-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS arm64", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-darwin-x64/package.json b/packages/libnode-darwin-x64/package.json index dc9f3e06f..9a1e43bc6 100644 --- a/packages/libnode-darwin-x64/package.json +++ b/packages/libnode-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS x64", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-linux-arm64-gnu/package.json b/packages/libnode-linux-arm64-gnu/package.json index 108e179cd..1ba37111f 100644 --- a/packages/libnode-linux-arm64-gnu/package.json +++ b/packages/libnode-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux arm64 glibc", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-linux-x64-gnu/package.json b/packages/libnode-linux-x64-gnu/package.json index af2c3bace..e361aa369 100644 --- a/packages/libnode-linux-x64-gnu/package.json +++ b/packages/libnode-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux x64 glibc", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json index d1ac54f3e..78198ec25 100644 --- a/packages/llvm-darwin-arm64/package.json +++ b/packages/llvm-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-x64/package.json b/packages/llvm-darwin-x64/package.json index b96a90cae..1a9ed9d2c 100644 --- a/packages/llvm-darwin-x64/package.json +++ b/packages/llvm-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-gnu/package.json b/packages/llvm-linux-arm64-gnu/package.json index 49796f522..c72277e4a 100644 --- a/packages/llvm-linux-arm64-gnu/package.json +++ b/packages/llvm-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-musl/package.json b/packages/llvm-linux-arm64-musl/package.json index 3001949c6..a9489efdd 100644 --- a/packages/llvm-linux-arm64-musl/package.json +++ b/packages/llvm-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux arm64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-gnu/package.json b/packages/llvm-linux-x64-gnu/package.json index 11b375ba4..6949973db 100644 --- a/packages/llvm-linux-x64-gnu/package.json +++ b/packages/llvm-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-musl/package.json b/packages/llvm-linux-x64-musl/package.json index ffe1ca7ae..d1ab5309a 100644 --- a/packages/llvm-linux-x64-musl/package.json +++ b/packages/llvm-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux x64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-win32-x64-msvc/package.json b/packages/llvm-win32-x64-msvc/package.json index 77e6fe687..9d41b3df1 100644 --- a/packages/llvm-win32-x64-msvc/package.json +++ b/packages/llvm-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Windows x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/node/package.json b/packages/node/package.json index 6a080da99..bb82fd4d8 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/node", - "version": "0.4.5", + "version": "0.4.6", "description": "Call into Node.js from scriptc programs: run an export of a JavaScript module in a Node.js helper process and copy JSON data both ways. Under Node.js the calls run in-process.", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-android-arm64/package.json b/packages/runtime-android-arm64/package.json index 7aea1da1e..405d2e8b1 100644 --- a/packages/runtime-android-arm64/package.json +++ b/packages/runtime-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-android-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for Android arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-arm64/package.json b/packages/runtime-darwin-arm64/package.json index ae068a00a..06e7033d3 100644 --- a/packages/runtime-darwin-arm64/package.json +++ b/packages/runtime-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for macOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-x64/package.json b/packages/runtime-darwin-x64/package.json index 7dbdaf6b9..02ed6b3af 100644 --- a/packages/runtime-darwin-x64/package.json +++ b/packages/runtime-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for macOS x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-arm64/package.json b/packages/runtime-ios-arm64/package.json index 8a868a606..17af72e9e 100644 --- a/packages/runtime-ios-arm64/package.json +++ b/packages/runtime-ios-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for iOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-simulator-arm64/package.json b/packages/runtime-ios-simulator-arm64/package.json index 011d41b39..807ef48c8 100644 --- a/packages/runtime-ios-simulator-arm64/package.json +++ b/packages/runtime-ios-simulator-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-simulator-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for iOS simulator arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-gnu/package.json b/packages/runtime-linux-arm64-gnu/package.json index 92a3192e7..a00679bab 100644 --- a/packages/runtime-linux-arm64-gnu/package.json +++ b/packages/runtime-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux arm64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-musl/package.json b/packages/runtime-linux-arm64-musl/package.json index 5e751d829..c7f91ba98 100644 --- a/packages/runtime-linux-arm64-musl/package.json +++ b/packages/runtime-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux arm64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-gnu/package.json b/packages/runtime-linux-x64-gnu/package.json index 63c045385..edfd142c6 100644 --- a/packages/runtime-linux-x64-gnu/package.json +++ b/packages/runtime-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux x64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-musl/package.json b/packages/runtime-linux-x64-musl/package.json index a8602b395..e1e1d6955 100644 --- a/packages/runtime-linux-x64-musl/package.json +++ b/packages/runtime-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux x64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-wasm32-wasi/package.json b/packages/runtime-wasm32-wasi/package.json index 799863cf0..4aad3dbd4 100644 --- a/packages/runtime-wasm32-wasi/package.json +++ b/packages/runtime-wasm32-wasi/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-wasm32-wasi", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for WASI Preview 1", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-win32-x64-msvc/package.json b/packages/runtime-win32-x64-msvc/package.json index 421f1f71f..8409e7d25 100644 --- a/packages/runtime-win32-x64-msvc/package.json +++ b/packages/runtime-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Windows x64 MSVC", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 90140ab15..97a5b3344 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime", - "version": "0.4.5", + "version": "0.4.6", "description": "scriptc native runtime sources and vendored dependencies", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/threads/package.json b/packages/threads/package.json index 6ebcf5b8b..56811f3e4 100644 --- a/packages/threads/package.json +++ b/packages/threads/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/threads", - "version": "0.4.5", + "version": "0.4.6", "description": "Zero-copy publication of immutable object graphs to worker threads (scriptc), with a deep-freezing Node.js implementation", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/types/package.json b/packages/types/package.json index b2aa805be..547161cb9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/types", - "version": "0.4.5", + "version": "0.4.6", "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics", "license": "Apache-2.0", "homepage": "https://scriptc.dev",