From 5fa44d9243db78646e4226dbfd454288b1436ec0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:53 -0700 Subject: [PATCH 01/12] Share modules between a program and its Node islands through loader hooks (NI-6) Node islands stack, part 5 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/diagnostics/diagnostic.ts | 38 ++ .../src/frontend/lowering/lower-modules.ts | 10 + .../frontend/lowering/lower-node-island.ts | 96 +++- .../frontend/lowering/lower-shared-modules.ts | 529 ++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 10 +- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 8 + packages/compiler/src/node-islands/libnode.ts | 2 +- .../compiler/src/node-islands/snapshot.ts | 2 +- .../main.mjs.json | 7 + .../main.mjs.json | 7 + .../main.mjs.json | 6 + .../main.mjs.json | 6 + .../main.mjs.json | 7 + .../libnode-common/bridge/scr_node_bridge.cc | 88 ++- packages/runtime/src/scr_json.c | 12 + packages/runtime/src/scr_node_bridge.h | 23 +- packages/runtime/src/scr_node_island.c | 8 + packages/runtime/src/scr_node_island.h | 11 + packages/runtime/src/scr_node_island_export.c | 374 ++++++++++++- packages/runtime/src/scr_node_island_handle.c | 20 +- .../runtime/src/scr_node_island_hooks.cjs | 146 +++++ packages/runtime/src/scr_node_island_host.cjs | 16 +- packages/runtime/src/scr_node_island_host.h | 168 +++++- .../runtime/test/node-island-host.test.ts | 2 +- scripts/node-island-host.mjs | 7 +- .../main.mjs | 28 + .../plugin.mjs | 37 ++ .../state.mjs | 19 + .../6851-node-island-shared-class/island.mjs | 35 ++ .../6851-node-island-shared-class/main.mjs | 23 + .../6851-node-island-shared-class/shapes.mjs | 35 ++ .../6852-node-island-shared-cycle/main.mjs | 10 + .../node_modules/core/index.d.ts | 5 + .../node_modules/core/index.js | 16 + .../node_modules/core/package.json | 1 + .../node_modules/ext/index.d.ts | 3 + .../node_modules/ext/index.js | 19 + .../node_modules/ext/package.json | 1 + .../package.json | 1 + .../6853-node-island-shared-registry/main.mjs | 19 + .../node_modules/registry/index.d.ts | 13 + .../node_modules/registry/index.js | 13 + .../node_modules/registry/package.json | 1 + .../scenario.mjs | 26 + .../main.mjs | 18 + .../registry.mjs | 13 + .../scenario.mjs | 22 + .../main.mjs.json | 1 + .../main.mjs.json | 1 + .../main.mjs.json | 1 + .../main.mjs.json | 1 + .../main.mjs.json | 1 + tests/harness/node-island.test.ts | 2 +- 55 files changed, 1938 insertions(+), 34 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/lower-shared-modules.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json create mode 100644 packages/runtime/src/scr_node_island_hooks.cjs create mode 100644 tests/corpus/6850-node-island-shared-singleton/main.mjs create mode 100644 tests/corpus/6850-node-island-shared-singleton/plugin.mjs create mode 100644 tests/corpus/6850-node-island-shared-singleton/state.mjs create mode 100644 tests/corpus/6851-node-island-shared-class/island.mjs create mode 100644 tests/corpus/6851-node-island-shared-class/main.mjs create mode 100644 tests/corpus/6851-node-island-shared-class/shapes.mjs create mode 100644 tests/corpus/6852-node-island-shared-cycle/main.mjs create mode 100644 tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts create mode 100644 tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js create mode 100644 tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json create mode 100644 tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts create mode 100644 tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js create mode 100644 tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json create mode 100644 tests/corpus/6852-node-island-shared-cycle/package.json create mode 100644 tests/corpus/6853-node-island-shared-registry/main.mjs create mode 100644 tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts create mode 100644 tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js create mode 100644 tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json create mode 100644 tests/corpus/6853-node-island-shared-registry/scenario.mjs create mode 100644 tests/corpus/6854-node-island-shared-registry-native/main.mjs create mode 100644 tests/corpus/6854-node-island-shared-registry-native/registry.mjs create mode 100644 tests/corpus/6854-node-island-shared-registry-native/scenario.mjs create mode 100644 tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json create mode 100644 tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json create mode 100644 tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json create mode 100644 tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json create mode 100644 tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 325328ec8..589fd8b00 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -966,6 +966,9 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.construct": "scr_node_island_construct", "nodeIsland.instanceof": "scr_node_island_instanceof", "nodeIsland.classMethods": "scr_node_island_class_methods", + "nodeIsland.share": "scr_node_island_share", + "nodeIsland.sharedTdz": "scr_node_island_shared_tdz", + "nodeIsland.classMethodsOf": "scr_node_island_class_methods_of", "dyn.keySet": "scr_dyn_key_set", "dyn.keySetComputed": "scr_dyn_key_set_computed", "dyn.keyDelete": "scr_dyn_key_delete", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 71db0c492..bdbe2724e 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -258,6 +258,44 @@ export function nativeSubclassOfIslandDiag(loc: SrcLoc): ScrDiagnostic { }; } +/** SC8020 — a stateful module the program compiles is duplicated across + * the Node island boundary (an explicit "duplicate", or a form NI-6 cannot + * share yet): each side has its own state. */ +export function sharedModuleDuplicatedDiag( + module: string, + why: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8020", + message: `module '${module}' is duplicated across the Node island boundary although it is stateful: ${why}; the program and its islands each see their own copy`, + loc, + hint: 'remove "share": "duplicate" from its scriptc.packages entry to share one instance', + }; +} + +/** SC8022 — island code extends a class of a module the program compiles. + * A native instance cannot take an island subclass's overrides, so the + * module is not owned by the program: a package runs in Node for both + * sides (the build retries with it as a Node island); a program module is + * refused. */ +export function sharedClassExtendedDiag( + pkg: string | null, + file: string, + base: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8022", + message: + pkg !== null + ? `package '${pkg}' is shared with a Node island that extends its class '${base}': it runs in Node for both sides` + : `module '${file}' cannot be shared with a Node island that extends its class '${base}' (island subclasses of native classes)`, + loc, + hint: "move the subclass into the program, or mark the module as a Node island", + }; +} + /** SC8002 — Node islands are off (or limited to marked islands) and an * npm package is outside the native subset. */ export function nodeIslandsOffDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5952f15ce..ed4ca9f27 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -14,6 +14,11 @@ import * as ts from "../ts7/adapter.js"; import { collectRebindableFunctions, isRebindableFunction } from "./rebindable-functions.js"; import { bindingSource } from "../binding-source.js"; import type { Lowerer } from "./lowerer.js"; +import { + sharedInitFlagStores, + sharedModulePlans, + sharedModuleRegistrations, +} from "./lower-shared-modules.js"; import { dirname as dirnamePath, resolve as resolvePath } from "node:path"; import { packageNameOfPath, probeNodeImportRefusal } from "../npm.js"; import { isNpmStaticPackage } from "../npm-static.js"; @@ -2973,6 +2978,10 @@ export function lowerFileInit( }); } if (sf === lowerer.entry) header.push(...lowerer.punycodeLinkWarning()); + // Modules shared with Node islands register before any module runs + // (NI-6); the plan also creates their declarations' TDZ flags. + if (sharedModulePlans(lowerer).length > 0 && sf === lowerer.entry) + header.push(...sharedModuleRegistrations(lowerer)); // Top-level `this` of a CommonJS module: its object exists before the // module's first statement (lowerCjsTopLevelThis). const exportsThis = lowerer.cjsExportsThis.get(sf); @@ -3209,6 +3218,7 @@ export function lowerFileInit( stmt, deferred ? [deferred] : lowerer.lowerStmts([stmt]), ); + if (lowerer.sharedInitFlags.size > 0) stmtIr.push(...sharedInitFlagStores(lowerer, stmt)); // Class EXPRESSIONS inside this statement queued their static // inits while it lowered: they land immediately before it — JS's // order for the supported whole-initializer positions. diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 24c3c91cc..823ca1384 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -590,8 +590,27 @@ function withClassMethods( className: string, blame: ts.Node, ): IrExpr { + const builder = classMethodsBuilderOf(lowerer, className, blame, value.loc); + if (builder === null) return value; + return { + kind: "libCall", + fn: "nodeIsland.classMethods", + args: [value, builder], + type: DYN, + loc: value.loc, + }; +} + +/** The builder of a class's method table (a dyn closure answering + * {name: unbound method value}), or null for a class without methods. */ +function classMethodsBuilderOf( + lowerer: Lowerer, + className: string, + blame: ts.Node, + loc: SrcLoc, +): IrExpr | null { const info = lowerer.classes.get(className); - if (info === undefined || info.def.runtime) return value; + if (info === undefined || info.def.runtime) return null; const names: string[] = []; for ( let c: typeof info | undefined = info; @@ -601,8 +620,7 @@ function withClassMethods( for (const m of c.def.methods ?? []) if (!names.includes(m) && m !== "constructor") names.push(m); } - if (names.length === 0) return value; - const loc = value.loc; + if (names.length === 0) return null; const builder = `%island.methods:${className}`; if (!lowerer.classMethodValueHelpers.has(builder)) { lowerer.classMethodValueHelpers.add(builder); @@ -631,13 +649,75 @@ function withClassMethods( type: funcTypeFromParamShapes([], DYN), loc, }; - return { - kind: "libCall", - fn: "nodeIsland.classMethods", - args: [value, lowerer.coerceToExpected(closure, DYN)], - type: DYN, + return lowerer.coerceToExpected(closure, DYN); +} + +/** NI-6: the method-table builder of the class a shared module exports + * (`ident` is its declaration's name), or null. */ +export function classMethodsBuilder( + lowerer: Lowerer, + ident: ts.Identifier, + loc: SrcLoc, +): IrExpr | null { + const decl = ident.parent; + if (decl === undefined || !ts.isClassDeclaration(decl)) return null; + return classMethodsBuilderOf(lowerer, lowerer.classNamer(decl), ident, loc); +} + +/** NI-6: `new C(...)` from island code for the class a shared module + * exports: a function with the constructor's parameters (its dyn thunk + * checks the island's arguments) that constructs an instance, as a dyn + * closure, or null when the constructor's shape is not a plain list of + * parameters (the island's `new` then throws). */ +export function classConstructHelper( + lowerer: Lowerer, + ident: ts.Identifier, + loc: SrcLoc, +): IrExpr | null { + const decl = ident.parent; + if (decl === undefined || !ts.isClassDeclaration(decl)) return null; + const className = lowerer.classNamer(decl); + const info = lowerer.classes.get(className); + if (info === undefined || info.def.runtime || info.localClass || info.generic) return null; + for (let c: typeof info | null = info; c; c = c.base) if (c.def.baseValueGlobal) return null; + if (lowerer.inheritsBuiltinErrorCtor(info)) return null; + const shapes = info.ctorParams; + if (shapes.some((s) => s.mode !== "required" && s.mode !== "omittable")) return null; + const name = `%island.new:${className}`; + if (!lowerer.classMethodValueHelpers.has(name)) { + lowerer.classMethodValueHelpers.add(name); + lowerer.noteEdge(`%${className}.constructor`); + const params = shapes.map((s, i) => ({ localId: `p${i}`, name: `p${i}`, type: s.type })); + const objectType: IrType = { kind: "object", className }; + lowerer.liftedFns.push({ + name, + params, + returnType: objectType, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body: [ + { + kind: "return", + value: { + kind: "new", + className, + args: params.map((p) => varRef(p.localId, p.type, loc)), + type: objectType, + loc, + }, + loc, + }, + ], + loc, + }); + } + const closure: IrExpr = { + kind: "closure", + fnName: name, + captures: [], + type: funcTypeFromParamShapes(shapes, { kind: "object", className }), loc, }; + return lowerer.coerceToExpected(closure, DYN); } /** Native values NI-5 exports into the island by reference: functions, diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts new file mode 100644 index 000000000..4a63beb91 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -0,0 +1,529 @@ +/* Shared modules between the program and its Node islands (scope-B NI-6, + * DESIGN.md §2.6). A module the program compiles that island code also + * imports would otherwise exist twice, with two states. Rule: each module + * has exactly one owner. + * + * - Program modules (user code): always shared, owned by the program. + * - npm packages compiled natively and imported by island code: shared + * when stateful (module-level state written after evaluation, exported + * classes, evaluation effects), duplicated when pure (each side has its + * own copy, which nobody can observe). `scriptc.packages..share` + * ("shared" | "duplicate") overrides; duplicating a stateful package is + * SC8020, a warning, because the two copies diverge. + * + * A shared module is registered at program start (nodeIsland.share, never + * loads Node) with its accessor: a generated function that answers the + * current values of its exports as a dyn array, the TDZ sentinel for a let + * or const whose declaration has not run yet (a flag the module's %init + * sets right after the declaration). The bootstrap's loader hooks + * (runtime scr_node_island_hooks.cjs) resolve island imports of the module + * to a generated ES module over those values. + * + * Island code is found syntactically: the files requireModule() loads and + * every JavaScript file of an island package, then the files they import + * that the program does not compile (Node's side of the closure). An + * import that resolves to a compiled module makes it a candidate. A + * specifier inside a comment or string can only add a candidate (an extra + * shared module), never lose one. */ +import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { resolveFrom } from "../../node-islands/resolve.js"; +import { join } from "node:path"; +import * as ts from "../ts7/adapter.js"; +import { locOf, isCjsJsFile } from "../program.js"; +import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js"; +import { BOOL, DYN, STRING, VOID, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import { resolveBareModule } from "../resolve.js"; +import { packageDirOf } from "../../node-islands/origin.js"; +import { readProjectConfig } from "../../project-config.js"; +import { + sharedClassExtendedDiag, + sharedModuleDuplicatedDiag, +} from "../../diagnostics/diagnostic.js"; +import { classConstructHelper, classMethodsBuilder } from "./lower-node-island.js"; +import { dynUndefinedExpr } from "../../ir/build.js"; +import { funcTypeFromParamShapes } from "./call-signatures.js"; + +type ExportKind = "function" | "class" | "let" | "const" | "var"; + +interface SharedExport { + name: string; + kind: ExportKind; + /** The declaration's name: its reference is the export's value. */ + ident: ts.Identifier; + /** let/const: the flag global set after the declaration ran. */ + flag?: string; +} + +export interface SharedModulePlan { + sf: ts.SourceFile; + /** The real path Node resolves the module to. */ + path: string; + exports: SharedExport[]; +} + +const MAX_ISLAND_FILES = 4000; +const SPECIFIERS = [ + /\bfrom\s*["']([^"'\n]+)["']/g, + /\bimport\s*["']([^"'\n]+)["']/g, + /\brequire\s*\(\s*["']([^"'\n]+)["']\s*\)/g, + /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/g, +]; +const REQUIRE_MODULE = /\brequireModule\s*\(\s*["']([^"'\n]+)["']\s*\)/g; + +function real(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function isJsFile(path: string): boolean { + return /\.(?:[cm]?js)$/.test(path); +} + +/** The files `spec` may name from `from` (require and import conditions). */ +function resolveAll(from: string, spec: string): string[] { + if (spec.startsWith("node:")) return []; + const out = new Set(); + try { + out.add(real(resolveFrom(from, spec))); + } catch { + /* not resolvable for require */ + } + if (!spec.startsWith(".") && !spec.startsWith("/")) { + for (const kind of ["import", "require"] as const) { + const js = resolveBareModule(from, spec, "js-only", kind); + if (js !== null) out.add(real(js.typesFile)); + } + } + return [...out]; +} + +function packageJsFiles(dir: string, out: string[]): void { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + if (out.length >= MAX_ISLAND_FILES) return; + if (name === "node_modules" || name.startsWith(".")) continue; + const path = join(dir, name); + let isDirectory: boolean; + try { + isDirectory = statSync(path).isDirectory(); + } catch { + continue; + } + if (isDirectory) packageJsFiles(path, out); + else if (isJsFile(name)) out.push(real(path)); + } +} + +function isExported(stmt: ts.Node): boolean { + const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined; + return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true; +} + +function isDefaultExport(stmt: ts.Node): boolean { + const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined; + return mods?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) === true; +} + +/** The top-level declarations of a module by name. */ +function localDeclarations( + sf: ts.SourceFile, +): Map { + const out = new Map(); + for (const stmt of sf.statements.nodes) { + if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) + out.set(stmt.name.text, { kind: "function", ident: stmt.name }); + else if (ts.isClassDeclaration(stmt) && stmt.name) + out.set(stmt.name.text, { kind: "class", ident: stmt.name }); + else if (ts.isVariableStatement(stmt)) { + const flags = stmt.declarationList.flags; + const kind: ExportKind = + (flags & ts.NodeFlags.Const) !== 0 + ? "const" + : (flags & ts.NodeFlags.Let) !== 0 + ? "let" + : "var"; + for (const d of stmt.declarationList.declarations.nodes) + if (ts.isIdentifier(d.name)) out.set(d.name.text, { kind, ident: d.name }); + } + } + return out; +} + +/** The module's value exports (re-exports and default exports of + * expressions are not shared: NOTES.md shortcut). */ +function moduleExports(sf: ts.SourceFile): SharedExport[] { + const locals = localDeclarations(sf); + const out: SharedExport[] = []; + for (const stmt of sf.statements.nodes) { + if (ts.isExportDeclaration(stmt)) { + if (stmt.moduleSpecifier || stmt.isTypeOnly || !stmt.exportClause) continue; + if (!ts.isNamedExports(stmt.exportClause)) continue; + for (const el of stmt.exportClause.elements.nodes) { + if (el.isTypeOnly) continue; + const local = locals.get((el.propertyName ?? el.name).text); + if (local !== undefined) out.push({ name: el.name.text, ...local }); + } + continue; + } + if (!isExported(stmt)) continue; + const def = isDefaultExport(stmt); + if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) + out.push({ name: def ? "default" : stmt.name.text, kind: "function", ident: stmt.name }); + else if (ts.isClassDeclaration(stmt) && stmt.name) + out.push({ name: def ? "default" : stmt.name.text, kind: "class", ident: stmt.name }); + else if (ts.isVariableStatement(stmt)) { + const flags = stmt.declarationList.flags; + const kind: ExportKind = + (flags & ts.NodeFlags.Const) !== 0 + ? "const" + : (flags & ts.NodeFlags.Let) !== 0 + ? "let" + : "var"; + for (const d of stmt.declarationList.declarations.nodes) + if (ts.isIdentifier(d.name)) out.push({ name: d.name.text, kind, ident: d.name }); + } + } + return out; +} + +/** The name of a class imported from `spec` that the island file extends + * (`class X extends Base` or `extends ns.Base`), or null. */ +function extendedImport(text: string, spec: string): string | null { + const quoted = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const names: string[] = []; + const named = new RegExp( + `import\\s*(?:(\\w+)\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*["']${quoted}["']`, + "g", + ); + for (const m of text.matchAll(named)) { + if (m[1] !== undefined) names.push(m[1]); + for (const part of m[2]!.split(",")) { + const local = part + .trim() + .split(/\s+as\s+/) + .at(-1) + ?.trim(); + if (local) names.push(local); + } + } + const star = new RegExp(`import\\s*\\*\\s*as\\s+(\\w+)\\s*from\\s*["']${quoted}["']`, "g"); + for (const m of text.matchAll(star)) names.push(`${m[1]!}\\.\\w+`); + const def = new RegExp(`import\\s+(\\w+)\\s+from\\s*["']${quoted}["']`, "g"); + for (const m of text.matchAll(def)) names.push(m[1]!); + for (const name of names) { + const m = new RegExp(`\\bclass\\s+[\\w$]*\\s*extends\\s+(${name})\\b`).exec(text); + if (m !== null) return m[1]!; + } + return null; +} + +const MUTATING_METHODS = new Set([ + "set", + "add", + "delete", + "clear", + "push", + "pop", + "shift", + "unshift", + "splice", + "sort", + "reverse", + "fill", + "copyWithin", +]); + +/** Why a module is stateful (one reason), or null when it is pure: no + * module-level binding reachable from its exports is written after + * evaluation, no exported class (its instances may cross or meet + * instanceof in island code), and no top-level effect beyond declarations. */ +export function statefulReason(sf: ts.SourceFile): string | null { + const moduleLevel = new Map(); // name -> kind + for (const stmt of sf.statements.nodes) { + if (ts.isClassDeclaration(stmt) && isExported(stmt)) + return `it exports the class '${stmt.name?.text ?? "default"}'`; + if (ts.isVariableStatement(stmt)) { + const flags = stmt.declarationList.flags; + for (const d of stmt.declarationList.declarations.nodes) { + if (!ts.isIdentifier(d.name)) continue; + if ((flags & ts.NodeFlags.Const) === 0) { + if (isExported(stmt)) return `it exports the mutable binding '${d.name.text}'`; + moduleLevel.set(d.name.text, "let"); + } else { + moduleLevel.set(d.name.text, "const"); + } + } + continue; + } + if ( + ts.isFunctionDeclaration(stmt) || + ts.isClassDeclaration(stmt) || + ts.isImportDeclaration(stmt) || + ts.isExportDeclaration(stmt) || + ts.isInterfaceDeclaration(stmt) || + ts.isTypeAliasDeclaration(stmt) || + ts.isExportAssignment(stmt) || + ts.isEmptyStatement(stmt) + ) + continue; + if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives + return "its evaluation has effects beyond declarations"; + } + // Writes from function bodies to module-level bindings. + let reason: string | null = null; + const visit = (node: ts.Node, inFunction: boolean): void => { + if (reason !== null) return; + const fn = ts.isFunctionLike(node); + if (inFunction) { + if (ts.isBinaryExpression(node) && ts.isAssignmentOperator(node.operatorToken.kind)) { + let target: ts.Node = node.left; + while (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) + target = target.expression; + if (ts.isIdentifier(target) && moduleLevel.has(target.text)) + reason = `its function bodies write the module-level binding '${target.text}'`; + } else if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + ts.isIdentifier(node.operand) && + moduleLevel.has(node.operand.text) + ) { + reason = `its function bodies write the module-level binding '${node.operand.text}'`; + } else if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + moduleLevel.has(node.expression.expression.text) && + MUTATING_METHODS.has(node.expression.name.text) + ) { + reason = `its function bodies mutate the module-level '${node.expression.expression.text}'`; + } + } + ts.forEachChild(node, (child) => visit(child, inFunction || fn)); + }; + visit(sf, false); + return reason; +} + +/** The modules the program shares with its islands (computed once). */ +export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] { + if (lowerer.sharedModules !== null) return lowerer.sharedModules; + const plans: SharedModulePlan[] = []; + lowerer.sharedModules = plans; + const compiled = new Map(); + for (const sf of lowerer.moduleOrder) { + if (sf.isDeclarationFile) continue; + compiled.set(real(sf.fileName), sf); + } + // Island roots: requireModule() targets and island package files. + const queue: string[] = []; + for (const sf of lowerer.moduleOrder) { + const text = sf.text; + if (!text.includes("requireModule")) continue; + const from = real(sf.fileName); + for (const m of text.matchAll(REQUIRE_MODULE)) { + try { + queue.push(real(resolveFrom(from, m[1]!))); + } catch { + /* lowering reports it */ + } + } + } + for (const island of lowerer.nodeIslands?.packages.values() ?? []) + packageJsFiles(island.dir, queue); + if (queue.length === 0) return plans; + const seen = new Set(); + const candidates = new Set(); + // Classes island code extends, by the compiled module that exports them. + const extended = new Map(); + while (queue.length > 0 && seen.size < MAX_ISLAND_FILES) { + const file = queue.pop()!; + if (seen.has(file) || compiled.has(file) || !isJsFile(file)) continue; + seen.add(file); + let text: string; + try { + text = readFileSync(file, "utf8"); + } catch { + continue; + } + for (const re of SPECIFIERS) { + for (const m of text.matchAll(re)) { + for (const target of resolveAll(file, m[1]!)) { + const sf = compiled.get(target); + if (sf !== undefined) { + candidates.add(sf); + const base = extendedImport(text, m[1]!); + if (base !== null && !extended.has(sf)) extended.set(sf, base); + } else if (!seen.has(target)) queue.push(target); + } + } + } + } + if (candidates.size === 0) return plans; + const config = readProjectConfig(lowerer.entry.fileName); + for (const sf of lowerer.moduleOrder) { + if (!candidates.has(sf)) continue; + const path = real(sf.fileName); + const pkg = packageDirOf(path); + const loc: SrcLoc = { file: sf.fileName, start: 0, end: 0 }; + const share = pkg === null ? "shared" : (config.packages.get(pkg.name)?.share ?? "auto"); + const stateful = pkg === null ? "it is a program module" : statefulReason(sf); + const base = extended.get(sf); + if (base !== undefined) { + // Island code extends one of its classes: a native instance cannot + // take an island subclass's overrides, so the module cannot be owned + // by the program. A package then runs in Node for both sides + // (Node-owned, through handles; the build retries with it as an + // island); a program module is refused. + lowerer.pushDiag(sharedClassExtendedDiag(pkg?.name ?? null, sf.fileName, base, loc)); + continue; + } + if (isCjsJsFile(sf, lowerer.program)) { + // Shortcut: CommonJS modules are not shared yet; each side keeps its + // copy, so a stateful one is reported. + if (stateful !== null) + lowerer.sharedModuleWarnings.push( + sharedModuleDuplicatedDiag( + pkg?.name ?? sf.fileName, + `${stateful}, and CommonJS modules cannot be shared yet`, + loc, + ), + ); + continue; + } + if (share === "duplicate" || (share === "auto" && stateful === null)) { + if (stateful !== null) + lowerer.sharedModuleWarnings.push( + sharedModuleDuplicatedDiag( + pkg?.name ?? sf.fileName, + `${stateful}, and it is marked "share": "duplicate"`, + loc, + ), + ); + continue; + } + const exports = moduleExports(sf); + for (const e of exports) { + if (e.kind !== "let" && e.kind !== "const") continue; + const flag = `%g.shared.${plans.length}.${e.name}%initialized`; + lowerer.globalsList.push({ id: flag, name: "%initialized", type: BOOL, mutable: true }); + e.flag = flag; + let stmt: ts.Node | undefined = e.ident.parent; + while (stmt !== undefined && !ts.isVariableStatement(stmt)) stmt = stmt.parent; + if (stmt !== undefined) { + const list = lowerer.sharedInitFlags.get(stmt as ts.Statement) ?? []; + list.push(flag); + lowerer.sharedInitFlags.set(stmt as ts.Statement, list); + } + } + plans.push({ sf, path, exports }); + } + if (plans.length > 0) lowerer.usesNodeIsland = true; + return plans; +} + +/** The flag stores after a shared module's declaration statement. */ +export function sharedInitFlagStores(lowerer: Lowerer, stmt: ts.Statement): IrStmt[] { + const flags = lowerer.sharedInitFlags.get(stmt); + if (flags === undefined) return []; + const loc = locOf(stmt); + return flags.map((flag) => ({ + kind: "assign", + localId: flag, + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + })); +} + +/** The registrations the entry's %init runs first: one nodeIsland.share + * per shared module, with its accessor. */ +export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] { + const plans = sharedModulePlans(lowerer); + const out: IrStmt[] = []; + plans.forEach((plan, index) => { + const loc: SrcLoc = { file: plan.sf.fileName, start: 0, end: 0 }; + const name = `%island.shared:${index}`; + const ctx = newFnCtx(false, null, null, DYN); + lowerer.fnStack.push(ctx); + const elems: IrExpr[] = []; + const kept: SharedExport[] = []; + try { + for (const e of plan.exports) { + let value: IrExpr; + try { + value = lowerer.coerceToExpected(lowerer.lowerExpr(e.ident), DYN); + } catch (error) { + if (!(error instanceof PoisonError)) throw error; + continue; + } + if (e.kind === "class") { + const builder = classMethodsBuilder(lowerer, e.ident, loc) ?? dynUndefinedExpr(loc); + const construct = classConstructHelper(lowerer, e.ident, loc) ?? dynUndefinedExpr(loc); + value = { + kind: "libCall", + fn: "nodeIsland.classMethodsOf", + args: [value, builder, construct], + type: DYN, + loc, + }; + } + if (e.flag !== undefined) { + value = { + kind: "ternary", + cond: { kind: "varRef", localId: e.flag, type: BOOL, loc }, + then: value, + else_: { kind: "libCall", fn: "nodeIsland.sharedTdz", args: [], type: DYN, loc }, + type: DYN, + loc, + }; + } + elems.push(value); + kept.push(e); + } + } finally { + lowerer.fnStack.pop(); + } + lowerer.liftedFns.push({ + name, + params: [], + returnType: DYN, + locals: ctx.locals, + body: [{ kind: "return", value: { kind: "dynArrLit", elems, type: DYN, loc }, loc }], + loc, + }); + const exportsJson = JSON.stringify(kept.map((e) => ({ name: e.name, kind: e.kind }))); + out.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "nodeIsland.share", + args: [ + { kind: "strLit", value: plan.path, type: STRING, loc }, + { kind: "strLit", value: exportsJson, type: STRING, loc }, + lowerer.coerceToExpected( + { + kind: "closure", + fnName: name, + captures: [], + type: funcTypeFromParamShapes([], DYN), + loc, + }, + DYN, + ), + ], + type: VOID, + loc, + }, + loc, + }); + }); + return out; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 77cc10cc0..7c6f5b7c6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1224,7 +1224,10 @@ function lowerToIrWithIslands( } // Divergence warnings over the program's own modules: computed once, // after lowering, from the same checker (never part of a failure). - const divergences = scanDivergences(resultLowerer, moduleOrder); + const divergences = [ + ...scanDivergences(resultLowerer, moduleOrder), + ...resultLowerer.sharedModuleWarnings, + ]; timing("divergence-scan", { found: divergences.length }); if (divergences.length > 0) result = { ...result, divergences }; // The performance warning (SC6101): likely-hot functions of the program @@ -3106,6 +3109,11 @@ export class Lowerer { /** Island module loads, by the importing file and specifier: the module * global holding what the import form sees (lower-node-island.ts). */ readonly islandModules = new Map(); + /** NI-6: the modules shared with Node islands (lower-shared-modules.ts), + * their SC8020 warnings, and the TDZ flags their declarations set. */ + sharedModules: import("./lower-shared-modules.js").SharedModulePlan[] | null = null; + readonly sharedModuleWarnings: ScrDiagnostic[] = []; + readonly sharedInitFlags = new Map(); /** Per-directory answers of experimental-import.ts's declaration-file test. */ readonly experimentalFiles = new Map(); /** Interned LifetimeRegion.run helpers, by callback type key. */ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index e22569d11..26e4cc081 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -22,6 +22,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "nodeIsland.construct", "nodeIsland.instanceof", "nodeIsland.classMethods", + "nodeIsland.classMethodsOf", // Node's argument, cipher-name, IV, tag-length and key-length errors. "crypto.cipherNew", // Node's curve, key, point, encoding and format errors. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f619a1f1b..ecdb7e8a1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -204,6 +204,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** 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 }, + /** NI-6 shared modules: registers a module the program shares with its + * islands (real path, exports JSON, accessor); the TDZ sentinel an + * accessor answers; the method table and constructor helper of an + * exported class by its class value (either may be undefined; answers + * the class value). */ + "nodeIsland.share": { argTypes: [STRING, STRING, DYN], result: VOID }, + "nodeIsland.sharedTdz": { argTypes: [], result: DYN }, + "nodeIsland.classMethodsOf": { argTypes: [DYN, DYN, DYN], result: DYN }, /** Keyed WRITE on a dyn value — `h.onDone = cb` / `h["k"] = v` on a * checked-dynamic object (args: receiver, key string, value — all * borrowed; the runtime copies the key and retains the value in). An diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index 22642015f..c12ecd4e5 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 = 6; +export const NODE_BRIDGE_ABI = 7; /** The prefix of a recorded path relative to the executable's directory. */ export const ORIGIN = "$ORIGIN/"; diff --git a/packages/compiler/src/node-islands/snapshot.ts b/packages/compiler/src/node-islands/snapshot.ts index 650c5d5f3..bde0f9674 100644 --- a/packages/compiler/src/node-islands/snapshot.ts +++ b/packages/compiler/src/node-islands/snapshot.ts @@ -272,7 +272,7 @@ globalThis.__scriptcNodeIsland = { }, setTimer: noop, clearTimer: noop, refTimer: noop, refreshTimer: noop, setImmediate: noop, clearImmediate: noop, refImmediate: noop, - nextTick: noop, emit: noop, load: noop, + nextTick: noop, emit: noop, load: noop, sharedPush: noop, }; const fnToString = Function.prototype.toString; const hasOwn = Object.prototype.hasOwnProperty; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json new file mode 100644 index 000000000..0e4388f5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6850-node-island-shared-singleton/state.mjs", + "/tests/corpus/6850-node-island-shared-singleton/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json new file mode 100644 index 000000000..d8980e1f1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6851-node-island-shared-class/shapes.mjs", + "/tests/corpus/6851-node-island-shared-class/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json new file mode 100644 index 000000000..bcb9ef07d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6852-node-island-shared-cycle/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json new file mode 100644 index 000000000..4aac1857f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6853-node-island-shared-registry/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json new file mode 100644 index 000000000..5f3f514d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6854-node-island-shared-registry-native/registry.mjs", + "/tests/corpus/6854-node-island-shared-registry-native/main.mjs" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 362c6bf0c..0571e75ac 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -70,6 +70,10 @@ Global g_set_timer, g_clear_timer, g_ref_timer, g_refresh_timer; Global g_set_immediate, g_clear_immediate, g_ref_immediate; Global g_next_tick, g_emit; Global g_load; // the bootstrap's module loader (NI-4) +Global g_shared_push; // the loader hooks' binding updates (NI-6) +void SharedTable(const FunctionCallbackInfo &info); +void SharedGet(const FunctionCallbackInfo &info); +void SharedTdz(const FunctionCallbackInfo &info); char *Copy(const std::string &text, size_t *len) { char *out = static_cast(std::malloc(text.size() + 1)); @@ -237,6 +241,9 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar SetMethod(context, native, "settle", Settle); SetMethod(context, native, "run", Run); SetMethod(context, native, "listen", Listen); + SetMethod(context, native, "sharedTable", SharedTable); + SetMethod(context, native, "sharedGet", SharedGet); + SetMethod(context, native, "sharedTdz", SharedTdz); global->Set(context, Utf8(isolate, "__scriptcNodeIslandHost"), native).Check(); TryCatch try_catch(isolate); if (node::LoadEnvironment(g_setup->env(), bootstrap).IsEmpty()) { @@ -269,7 +276,8 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar !TakeFunction(isolate, context, d, "refImmediate", &g_ref_immediate) || !TakeFunction(isolate, context, d, "nextTick", &g_next_tick) || !TakeFunction(isolate, context, d, "emit", &g_emit) || - !TakeFunction(isolate, context, d, "load", &g_load)) + !TakeFunction(isolate, context, d, "load", &g_load) || + !TakeFunction(isolate, context, d, "sharedPush", &g_shared_push)) return Copy("the Node island bootstrap left an incomplete dispatcher", nullptr); global->Delete(context, Utf8(isolate, "__scriptcNodeIsland")).FromMaybe(false); global->Delete(context, Utf8(isolate, "__scriptcNodeIslandHost")).FromMaybe(false); @@ -596,7 +604,7 @@ void Stop() { DropAllExports(); for (Global *fn : {&g_set_timer, &g_clear_timer, &g_ref_timer, &g_refresh_timer, &g_set_immediate, - &g_clear_immediate, &g_ref_immediate, &g_next_tick, &g_emit}) + &g_clear_immediate, &g_ref_immediate, &g_next_tick, &g_emit, &g_shared_push}) fn->Reset(); isolate->Exit(); delete g_locker; @@ -865,6 +873,14 @@ void ExportCall(const FunctionCallbackInfo &info) { std::memset(&out, 0, sizeof out); int status; if (info.IsConstructCall()) { + // `class Sub extends NativeClass` in island code: the native instance + // cannot take the island prototype's overrides (NI-6 shortcut). + auto self = g_exports.find(id); + if (self != g_exports.end() && !info.NewTarget()->StrictEquals(self->second.Get(isolate))) { + isolate->ThrowException(v8::Exception::TypeError(Utf8( + isolate, "scriptc: island code cannot extend a native class of a shared module yet"))); + return; + } status = g_host->export_construct(id, args.data(), args.size(), &out); } else { ScrNodeValue self; @@ -876,6 +892,15 @@ void ExportCall(const FunctionCallbackInfo &info) { if (!result.IsEmpty()) info.GetReturnValue().Set(result); } +void ExportHasInstance(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + HostCall scope; + ScrNodeValue v; + FromV8(isolate, context, info[0], &v); + info.GetReturnValue().Set(g_host->export_instance_of(ExportIdArg(info.Data()), &v) == 1); +} + uint32_t HolderId(Local holder) { if (holder->InternalFieldCount() < 1) return 0; return ExportIdArg(holder->GetInternalField(0).As()); @@ -1055,6 +1080,14 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode fn->SetName(in->u.exp.name != nullptr ? FromWtf8(isolate, in->u.exp.name, in->u.exp.name_len) : String::Empty(isolate)); + if (in->flags & SCR_NVF_CONSTRUCTOR) { + // A native class: instanceof asks the runtime (NI-6). + fn->DefineOwnProperty(context, v8::Symbol::GetHasInstance(isolate), + Function::New(context, ExportHasInstance, data, 1).ToLocalChecked(), + static_cast(v8::DontEnum | v8::DontDelete | + v8::ReadOnly)) + .Check(); + } obj = fn; } else if (in->flags & SCR_NVF_PROMISE) { Local resolver = v8::Promise::Resolver::New(context).ToLocalChecked(); @@ -1515,6 +1548,56 @@ void DropHandle(uint32_t h) { Drop(h); } +// ── shared modules (NI-6) ────────────────────────────────────────────── +// Natives of the loader hooks (scr_node_island_hooks.cjs): the table of +// modules the program shares, the current value of one export, and the +// exports still in their temporal dead zone. + +void SharedTable(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + HostCall scope; + ScrNodeValue out; + std::memset(&out, 0, sizeof out); + g_host->shared_table(&out); + info.GetReturnValue().Set(FromWtf8(isolate, out.u.str.data, out.u.str.len)); +} + +void SharedTdz(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + HostCall scope; + ScrNodeValue out; + std::memset(&out, 0, sizeof out); + g_host->shared_tdz(&out); + info.GetReturnValue().Set(FromWtf8(isolate, out.u.str.data, out.u.str.len)); +} + +// sharedGet(mod, slot, mode, tdz): the value, or `tdz` for a binding in its +// temporal dead zone. +void SharedGet(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + HostCall scope; + ScrNodeValue out; + std::memset(&out, 0, sizeof out); + int status = g_host->shared_get(Uint32Arg(info, 0), Uint32Arg(info, 1), + static_cast(Uint32Arg(info, 2)), &out); + if (status == 3) { + info.GetReturnValue().Set(info[3]); + return; + } + Local result; + ThrowOrReturn(isolate, context, status, &out, &result); + if (!result.IsEmpty()) info.GetReturnValue().Set(result); +} + +void SharedPush(uint32_t mod, uint32_t slot, const ScrNodeValue *value) { + Enter e; + Local argv[] = {Integer::NewFromUnsigned(e.isolate, mod), + Integer::NewFromUnsigned(e.isolate, slot), + ToV8(e.isolate, e.context, value)}; + Helper(e, g_shared_push, 3, argv); +} + const ScrNodeBridge kBridge = { SCR_NODE_BRIDGE_ABI, NODE_VERSION_STRING, @@ -1551,6 +1634,7 @@ const ScrNodeBridge kBridge = { DropHandle, LiveHandles, ExportSettle, + SharedPush, }; } // namespace diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 452ccbe29..9922a09ba 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2130,6 +2130,18 @@ ScrDyn *scr_dyn_get_prototype(ScrDyn *object) { return scr_dyn_object_prototype(); } if (object == scr_builtin_array_prototype) return scr_dyn_object_prototype(); +#ifdef SCR_NODE_ISLANDS + /* An island object: its V8 prototype, through the __proto__ accessor + * (undefined for an object without Object.prototype: null). */ + if (scr_ni_is_handle(object)) { + ScrDyn *proto = scr_ni_get_str(object, "__proto__", 9); + if (proto != NULL && proto->kind == SCR_DYN_UNDEF) { + scr_dyn_release(proto); + return scr_dyn_new_null(); + } + return proto; + } +#endif if (object->kind == SCR_DYN_NULL || object->kind == SCR_DYN_UNDEF) { static const char message[] = "Cannot convert undefined or null to object"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index ca73792bb..674a9ff68 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 6u +#define SCR_NODE_BRIDGE_ABI 7u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -200,6 +200,24 @@ typedef struct ScrNodeBridgeHost { void (*export_watch)(uint32_t id); /* V8 collected the representative of `id`. */ void (*export_release)(uint32_t id); + /* `value instanceof ` for a native class representative + * (its Symbol.hasInstance): 1 or 0. */ + int (*export_instance_of)(uint32_t id, const ScrNodeValue *value); + /* ── shared modules (NI-6): modules the program compiled that island + * code imports resolve, through the loader hooks of the bootstrap, to + * generated modules over the live native exports. ── */ + /* The table, as SCR_NV_STRING JSON: [{"path","exports":[{"name","kind"}]}] + * (index = module id, export index = slot). */ + void (*shared_table)(ScrNodeValue *out); + /* The current value of a shared export. mode 0: read; 1: read and bind + * (the generated module holds it: later native changes are pushed with + * bridge->shared_push); 2: bind late (the binding is in its temporal + * dead zone: it is pushed once the native declaration ran). 0: *out is + * the value; 1: threw (*out thrown); 3: in its temporal dead zone. */ + int (*shared_get)(uint32_t mod, uint32_t slot, int mode, ScrNodeValue *out); + /* The exports in their temporal dead zone now, as SCR_NV_STRING JSON + * [[mod, slot], ...]. */ + void (*shared_tdz)(ScrNodeValue *out); } ScrNodeBridgeHost; typedef struct ScrNodeBridge { @@ -290,6 +308,9 @@ typedef struct ScrNodeBridge { uint32_t (*live_handles)(void); /* Settles the V8 promise representing native promise `id` (NI-5). */ void (*export_settle)(uint32_t id, int rejected, const ScrNodeValue *value); + /* NI-6: the native value of shared export (mod, slot) changed: the + * generated module's binding takes `value`. */ + void (*shared_push)(uint32_t mod, uint32_t slot, const ScrNodeValue *value); } ScrNodeBridge; #endif /* SCR_NODE_BRIDGE_H */ diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index fe27acd0c..1a6ce307f 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -347,6 +347,9 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) { job(arg); return; } + /* Island code is about to run: it sees the current native values of + * shared exports (NI-6). */ + scr_ni_shared_refresh(); if (ni_stack == NULL) { ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); if (ni_stack == MAP_FAILED) ni_oom(); @@ -446,6 +449,7 @@ static void ni_host_exit(int code) { static void ni_host_run(uint32_t kind, uint32_t token) { NI_MARK_BEGIN; scr_loop_hosted_run(kind, token); + scr_ni_shared_refresh(); NI_MARK_END; } @@ -489,6 +493,10 @@ static const ScrNodeBridgeHost ni_host = { &scr_ni_host_export_keys, &scr_ni_host_export_watch, &scr_ni_host_export_release, + &scr_ni_host_export_instance_of, + &scr_ni_host_shared_table, + &scr_ni_host_shared_get, + &scr_ni_host_shared_tdz, }; void scr_node_island_emit(uint32_t event, uint32_t listener, ScrStr *name, int arg) { diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 5b12ef07f..9e818c3c3 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -119,7 +119,18 @@ int scr_ni_host_export_delete(uint32_t id, const struct ScrNodeValue *key, struc int scr_ni_host_export_keys(uint32_t id, struct ScrNodeValue *out); void scr_ni_host_export_watch(uint32_t id); void scr_ni_host_export_release(uint32_t id); +/* ── shared modules (scope-B NI-6, scr_node_island_export.c) ── */ +void scr_node_island_share(ScrStr *path, ScrStr *exports, ScrDyn *accessor); +ScrDyn *scr_node_island_shared_tdz(void); +ScrDyn *scr_node_island_class_methods_of(ScrDyn *cls, ScrDyn *builder, ScrDyn *ctor); +void scr_ni_shared_refresh(void); void scr_ni_exports_release_all(void); /* at exit, after Node stopped */ +void *scr_ni_value_out_begin(ScrDyn *v, struct ScrNodeValue *out, bool *ok); +void scr_ni_value_out_end(void *keep); +int scr_ni_host_export_instance_of(uint32_t id, const struct ScrNodeValue *value); +void scr_ni_host_shared_table(struct ScrNodeValue *out); +int scr_ni_host_shared_get(uint32_t mod, uint32_t slot, int mode, struct ScrNodeValue *out); +void scr_ni_host_shared_tdz(struct ScrNodeValue *out); /* Promise settlement callback (SCR_NODE_ISLANDS build of scr_async.c). */ void scr_promise_on_settle_sync(ScrPromise *in, void (*cb)(ScrPromise *dst, ScrPromise *src)); diff --git a/packages/runtime/src/scr_node_island_export.c b/packages/runtime/src/scr_node_island_export.c index 9a44e5110..3bbf52c0f 100644 --- a/packages/runtime/src/scr_node_island_export.c +++ b/packages/runtime/src/scr_node_island_export.c @@ -128,6 +128,8 @@ static void ni_ids_del(const void *key) { static const void *ni_identity(const ScrDyn *v) { switch (v->kind) { case SCR_DYN_FUNC: + /* A class value is boxed afresh per read: its identity is the class. */ + if (v->v.fn.class_obj != NULL) return v->v.fn.class_obj; return v->v.fn.clo != NULL ? (const void *)v->v.fn.clo : (const void *)v; case SCR_DYN_PROMISE: return v->v.promise; @@ -141,7 +143,7 @@ static const void *ni_identity(const ScrDyn *v) { uint32_t scr_ni_export_flags(const ScrDyn *v) { switch (v->kind) { case SCR_DYN_FUNC: - return SCR_NVF_FUNCTION; + return v->v.fn.class_obj != NULL ? SCR_NVF_FUNCTION | SCR_NVF_CONSTRUCTOR : SCR_NVF_FUNCTION; case SCR_DYN_PROMISE: return SCR_NVF_PROMISE; case SCR_DYN_ARR: @@ -296,6 +298,7 @@ int scr_ni_host_export_call(uint32_t id, const ScrNodeValue *this_, const ScrNod scr_dyn_release(self); ni_args_free(in, argc, small); scr_dyn_release(fn); + scr_ni_shared_refresh(); if (result == NULL || scr_exc_pending()) { scr_dyn_release(result); return ni_threw(out); @@ -303,6 +306,8 @@ int scr_ni_host_export_call(uint32_t id, const ScrNodeValue *this_, const ScrNod return ni_returned(result, out); } +static ScrDyn *ni_class_ctor(const ScrDyn *cls); + int scr_ni_host_export_construct(uint32_t id, const ScrNodeValue *args, size_t argc, ScrNodeValue *out) { ni_keep_set(NULL); @@ -312,13 +317,25 @@ int scr_ni_host_export_construct(uint32_t id, const ScrNodeValue *args, size_t a return ni_threw(out); } scr_dyn_retain(ctor); - ScrDyn *list = scr_dyn_new_arr(); - for (size_t i = 0; i < argc; i++) scr_dyn_arr_push(list, scr_ni_value_in(&args[i])); - ScrStr *what = scr_str_new("constructor", 11); - ScrDyn *result = scr_dyn_construct(ctor, list, what); - scr_str_release(what); - scr_dyn_release(list); + ScrDyn *result; + ScrDyn *helper = ni_class_ctor(ctor); + if (helper != NULL) { + /* A native class of a shared module (NI-6): its helper's thunk checks + * the arguments against the constructor's parameters. */ + ScrDyn *small[8]; + ScrDyn **in = ni_args_in(args, argc, small, 8); + result = scr_dyn_call(helper, in, argc, "constructor"); + ni_args_free(in, argc, small); + } else { + ScrDyn *list = scr_dyn_new_arr(); + for (size_t i = 0; i < argc; i++) scr_dyn_arr_push(list, scr_ni_value_in(&args[i])); + ScrStr *what = scr_str_new("constructor", 11); + result = scr_dyn_construct(ctor, list, what); + scr_str_release(what); + scr_dyn_release(list); + } scr_dyn_release(ctor); + scr_ni_shared_refresh(); if (result == NULL || scr_exc_pending()) { scr_dyn_release(result); return ni_threw(out); @@ -335,6 +352,7 @@ typedef struct { const char *key; size_t key_len; ScrDyn *table; /* +1 dyn object */ + ScrDyn *ctor; /* +1 dyn function constructing an instance (NI-6), or NULL */ } NiMethods; static NiMethods *ni_methods = NULL; static size_t ni_nmethods = 0, ni_methods_cap = 0; @@ -363,11 +381,68 @@ ScrDyn *scr_node_island_class_methods(ScrDyn *value, ScrDyn *builder) { ni_methods[ni_nmethods].key = value->v.typed_ref.type_key; /* static */ ni_methods[ni_nmethods].key_len = value->v.typed_ref.type_key_len; ni_methods[ni_nmethods].table = table; + ni_methods[ni_nmethods].ctor = NULL; ni_nmethods++; } return scr_dyn_retain(value); } +/* NI-6: the method table of a class registered through its class value + * (a shared module's exported class), keyed by the same type key its + * instances carry. Answers the class value. */ +/* The type key a class value's instances carry: "classval:C" -> "object:C". + * Answers whether `key`/`len` is it. */ +static bool ni_instance_key_is(const char *sig, const char *key, size_t len) { + if (sig == NULL || key == NULL || strncmp(sig, "classval:", 9) != 0) return false; + size_t rest = strlen(sig + 9); + return len == 7 + rest && memcmp(key, "object:", 7) == 0 && memcmp(key + 7, sig + 9, rest) == 0; +} + +static NiMethods *ni_methods_of_class(const ScrDyn *cls) { + if (cls->kind != SCR_DYN_FUNC || cls->v.fn.class_obj == NULL) return NULL; + for (size_t i = 0; i < ni_nmethods; i++) + if (ni_instance_key_is(cls->v.fn.sig, ni_methods[i].key, ni_methods[i].key_len)) + return &ni_methods[i]; + return NULL; +} + +ScrDyn *scr_node_island_class_methods_of(ScrDyn *cls, ScrDyn *builder, ScrDyn *ctor) { + if (cls->kind != SCR_DYN_FUNC || cls->v.fn.class_obj == NULL || cls->v.fn.sig == NULL || + strncmp(cls->v.fn.sig, "classval:", 9) != 0) + return scr_dyn_retain(cls); + NiMethods *known = ni_methods_of_class(cls); + if (known != NULL) { + if (known->ctor == NULL && ctor->kind == SCR_DYN_FUNC) known->ctor = scr_dyn_retain(ctor); + return scr_dyn_retain(cls); + } + ScrDyn *table = builder->kind == SCR_DYN_FUNC ? scr_dyn_call(builder, NULL, 0, "class methods") + : scr_dyn_new_obj(); + if (table == NULL) return NULL; + if (ni_nmethods == ni_methods_cap) { + ni_methods_cap = ni_methods_cap ? ni_methods_cap * 2 : 8; + NiMethods *grown = realloc(ni_methods, ni_methods_cap * sizeof *grown); + if (grown == NULL) ni_export_oom(); + ni_methods = grown; + } + size_t rest = strlen(cls->v.fn.sig + 9); + char *key = malloc(7 + rest + 1); /* lives as long as the program */ + if (key == NULL) ni_export_oom(); + memcpy(key, "object:", 7); + memcpy(key + 7, cls->v.fn.sig + 9, rest + 1); + ni_methods[ni_nmethods].key = key; + ni_methods[ni_nmethods].key_len = 7 + rest; + ni_methods[ni_nmethods].table = table; + ni_methods[ni_nmethods].ctor = ctor->kind == SCR_DYN_FUNC ? scr_dyn_retain(ctor) : NULL; + ni_nmethods++; + return scr_dyn_retain(cls); +} + +/* The constructor helper of a native class value (borrowed), or NULL. */ +static ScrDyn *ni_class_ctor(const ScrDyn *cls) { + NiMethods *m = ni_methods_of_class(cls); + return m != NULL ? m->ctor : NULL; +} + /* A prototype method of a class capsule (+1), or NULL. */ static ScrDyn *ni_class_method(const ScrDyn *target, const ScrDyn *key) { if (key->kind != SCR_DYN_STR) return NULL; @@ -390,8 +465,11 @@ int scr_ni_host_export_get(uint32_t id, const ScrNodeValue *key, ScrNodeValue *o int present = 0; if (pk->kind == SCR_DYN_STR) present = scr_dyn_in(target, pk->v.str); if (!present && !scr_exc_pending()) { + /* A prototype method of a class instance (its table). */ + ScrDyn *method = ni_class_method(target, pk); scr_dyn_release(pk); scr_dyn_release(k); + if (method != NULL) return ni_returned(method, out); return 2; } ScrDyn *result = scr_exc_pending() ? NULL : scr_dyn_reflect_get(target, pk, target); @@ -421,6 +499,7 @@ int scr_ni_host_export_set(uint32_t id, const ScrNodeValue *key, const ScrNodeVa bool ok = scr_dyn_reflect_set(target, k, v, target); scr_dyn_release(v); scr_dyn_release(k); + scr_ni_shared_refresh(); if (scr_exc_pending()) return ni_threw(out); (void)ok; /* a refused write is silent outside strict mode; Node's setter * interceptor reports the same */ @@ -436,6 +515,11 @@ int scr_ni_host_export_has(uint32_t id, const ScrNodeValue *key) { int present = 0; if (pk != NULL && pk->kind == SCR_DYN_STR) present = scr_dyn_in(target, pk->v.str); if (scr_exc_pending()) scr_exc_clear(); + if (!present && pk != NULL) { + ScrDyn *method = ni_class_method(target, pk); + present = method != NULL; + scr_dyn_release(method); + } scr_dyn_release(pk); scr_dyn_release(k); return present; @@ -470,7 +554,9 @@ int scr_ni_host_export_keys(uint32_t id, ScrNodeValue *out) { if (json == NULL || json->kind != SCR_DYN_STR) { scr_dyn_release(json); if (scr_exc_pending()) return ni_threw(out); - json = scr_dyn_new_str(scr_str_new("[]", 2)); + ScrStr *empty = scr_str_new("[]", 2); + json = scr_dyn_new_str(empty); + scr_str_release(empty); } memset(out, 0, sizeof *out); out->tag = SCR_NV_JSON; @@ -497,6 +583,19 @@ void scr_ni_host_export_release(uint32_t id) { scr_dyn_release(v); } +/* `value instanceof C` for the representative of native class C: the + * value is an instance of C's own type key (NI-6 shortcut: instances of + * native subclasses of C answer false; NOTES.md). */ +int scr_ni_host_export_instance_of(uint32_t id, const ScrNodeValue *value) { + ScrDyn *ctor = scr_ni_export_value(id); + if (ctor == NULL || ctor->kind != SCR_DYN_FUNC || ctor->v.fn.class_obj == NULL) return 0; + ScrDyn *v = scr_ni_value_in(value); + int is = v->kind == SCR_DYN_TYPED_REF && + ni_instance_key_is(ctor->v.fn.sig, v->v.typed_ref.type_key, v->v.typed_ref.type_key_len); + scr_dyn_release(v); + return is; +} + /* ── promises into Node ───────────────────────────────────────────────── */ typedef struct { @@ -567,8 +666,245 @@ void scr_ni_host_export_watch(uint32_t id) { scr_promise_on_settle_sync(p, ni_promise_settled); } -/* At exit, once Node stopped: everything the export table and the class - * tables hold (the RC audit counts them). */ +/* ── shared modules (scope-B NI-6) ──────────────────────────────────── + * A module the program compiled and island code also imports exists once: + * the program registers it (nodeIsland.share) with its accessor, a + * compiler-generated function answering the current values of its exports + * as a dyn array (the TDZ sentinel for a binding whose declaration has not + * run). The bootstrap's loader hooks (scr_node_island_hooks.cjs) resolve + * island imports of the module to a generated ES module whose bindings + * read those values; the bindings that can change (exported lets, and + * bindings still in their temporal dead zone when the generated module + * linked) are kept current by pushes: at each entry into Node and before + * each return from a native callback, the runtime compares the bound + * values with the last pushed ones and pushes the changes. Island code only + * runs inside those entries, so it never observes a stale binding. */ + +typedef struct { + ScrStr *path; /* +1 */ + ScrStr *exports; /* +1 JSON [{"name","kind"}] */ + ScrDyn *accessor; /* +1 */ + uint32_t nslots; + ScrDyn **last; /* +1 each, NULL when unbound */ + uint8_t *bound; /* 0 unbound, 1 bound, 2 bound late (not yet pushed) */ + uint32_t nbound; +} NiShared; + +static NiShared *ni_shared = NULL; +static uint32_t ni_nshared = 0, ni_shared_cap = 0; +static uint32_t ni_shared_bound = 0; /* bound slots over all modules */ +static bool ni_refreshing = false; +static ScrDyn *ni_tdz = NULL; + +static ScrDyn *ni_tdz_value(void) { + if (ni_tdz == NULL) ni_tdz = scr_dyn_new_obj(); + return ni_tdz; +} + +/* nodeIsland.sharedTdz: the sentinel an accessor answers for a binding in + * its temporal dead zone (+1). */ +ScrDyn *scr_node_island_shared_tdz(void) { return scr_dyn_retain(ni_tdz_value()); } + +/* nodeIsland.share(path, exports JSON, accessor): registers a shared + * module before any island code runs. Never loads Node. */ +void scr_node_island_share(ScrStr *path, ScrStr *exports, ScrDyn *accessor) { + for (uint32_t i = 0; i < ni_nshared; i++) + if (ni_shared[i].path->len == path->len && + memcmp(ni_shared[i].path->data, path->data, path->len) == 0) + return; + /* Released at exit (after Node stopped, when it started: atexit order). */ + if (ni_nshared == 0 && ni_shared_cap == 0) scr_atexit(scr_ni_exports_release_all); + if (ni_nshared == ni_shared_cap) { + ni_shared_cap = ni_shared_cap ? ni_shared_cap * 2 : 8; + NiShared *grown = realloc(ni_shared, ni_shared_cap * sizeof *grown); + if (grown == NULL) ni_export_oom(); + ni_shared = grown; + } + NiShared *m = &ni_shared[ni_nshared++]; + memset(m, 0, sizeof *m); + m->path = scr_str_retain(path); + m->exports = scr_str_retain(exports); + m->accessor = scr_dyn_retain(accessor); + /* The slot count: the accessor's array length is the export count. */ + uint32_t n = 0; + for (size_t i = 0; i < exports->len; i++) n += exports->data[i] == '{'; + m->nslots = n; + m->last = calloc(n > 0 ? n : 1, sizeof *m->last); + m->bound = calloc(n > 0 ? n : 1, 1); + if (m->last == NULL || m->bound == NULL) ni_export_oom(); +} + +/* The current export values of module `m` (+1 dyn array), or NULL. */ +static ScrDyn *ni_shared_values(NiShared *m) { + ScrDyn *values = scr_dyn_call(m->accessor, NULL, 0, "shared module"); + if (values == NULL) scr_exc_clear(); + return values; +} + +void scr_ni_host_shared_table(ScrNodeValue *out) { + ni_keep_set(NULL); + size_t len = 2; + for (uint32_t i = 0; i < ni_nshared; i++) + len += ni_shared[i].path->len * 2 + ni_shared[i].exports->len + 32; + char *buf = malloc(len); + if (buf == NULL) ni_export_oom(); + size_t at = 0; + buf[at++] = '['; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + if (i > 0) buf[at++] = ','; + at += (size_t)sprintf(buf + at, "{\"path\":\""); + for (size_t j = 0; j < m->path->len; j++) { + char c = m->path->data[j]; + if (c == '"' || c == '\\') buf[at++] = '\\'; + buf[at++] = c; + } + at += (size_t)sprintf(buf + at, "\",\"exports\":"); + memcpy(buf + at, m->exports->data, m->exports->len); + at += m->exports->len; + buf[at++] = '}'; + } + buf[at++] = ']'; + ScrStr *text = scr_str_new(buf, at); + ScrDyn *json = scr_dyn_new_str(text); + scr_str_release(text); + free(buf); + memset(out, 0, sizeof *out); + out->tag = SCR_NV_STRING; + out->u.str.data = json->v.str->data; + out->u.str.len = json->v.str->len; + ni_keep_set(json); +} + +static void ni_shared_bind(NiShared *m, uint32_t slot, ScrDyn *value, uint8_t how) { + if (m->bound[slot] == 0) { + m->nbound++; + ni_shared_bound++; + } + m->bound[slot] = how; + scr_dyn_release(m->last[slot]); + m->last[slot] = value != NULL ? scr_dyn_retain(value) : NULL; +} + +int scr_ni_host_shared_get(uint32_t mod, uint32_t slot, int mode, ScrNodeValue *out) { + ni_keep_set(NULL); + memset(out, 0, sizeof *out); + if (mod >= ni_nshared || slot >= ni_shared[mod].nslots) return 3; + NiShared *m = &ni_shared[mod]; + ScrDyn *values = ni_shared_values(m); + if (values == NULL) return 3; + ScrDyn *v = scr_dyn_arr_at(values, slot); + scr_dyn_release(values); + if (v == ni_tdz) { + scr_dyn_release(v); + if (mode == 2) ni_shared_bind(m, slot, NULL, 2); + return 3; + } + /* Arrays still cross as copies (NI-5): a copy of shared state would + * diverge silently, so the island read throws instead. */ + if (v->kind == SCR_DYN_ARR || + (v->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(v) != NULL)) { + scr_dyn_release(v); + static const char message[] = + "scriptc: an array exported by a shared module cannot cross into a Node island yet"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return ni_threw(out); + } + if (mode == 1 || mode == 2) ni_shared_bind(m, slot, v, 1); + return ni_returned(v, out); +} + +void scr_ni_host_shared_tdz(ScrNodeValue *out) { + ni_keep_set(NULL); + size_t cap = 64, at = 0; + char *buf = malloc(cap); + if (buf == NULL) ni_export_oom(); + buf[at++] = '['; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + ScrDyn *values = ni_shared_values(m); + if (values == NULL) continue; + for (uint32_t j = 0; j < m->nslots; j++) { + ScrDyn *v = scr_dyn_arr_at(values, j); + if (v == ni_tdz) { + if (at + 32 > cap) { + cap *= 2; + char *grown = realloc(buf, cap); + if (grown == NULL) ni_export_oom(); + buf = grown; + } + at += (size_t)sprintf(buf + at, "%s[%u,%u]", at > 1 ? "," : "", i, j); + } + scr_dyn_release(v); + } + scr_dyn_release(values); + } + buf[at++] = ']'; + ScrStr *text = scr_str_new(buf, at); + ScrDyn *json = scr_dyn_new_str(text); + scr_str_release(text); + free(buf); + memset(out, 0, sizeof *out); + out->tag = SCR_NV_STRING; + out->u.str.data = json->v.str->data; + out->u.str.len = json->v.str->len; + ni_keep_set(json); +} + +typedef struct { + uint32_t mod, slot; + const ScrNodeValue *value; +} NiPush; + +static void ni_push_job(void *p) { + NiPush *a = p; + scr_ni_bridge_loaded()->shared_push(a->mod, a->slot, a->value); +} + +/* Pushes the bound exports whose native values changed since the last + * push. Cheap when nothing is bound. */ +void scr_ni_shared_refresh(void) { + if (ni_shared_bound == 0 || ni_refreshing || scr_ni_bridge_loaded() == NULL) return; + ni_refreshing = true; + bool pending = scr_exc_pending(); + ScrCaught *saved = pending ? scr_exc_take() : NULL; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + if (m->nbound == 0) continue; + ScrDyn *values = ni_shared_values(m); + if (values == NULL) continue; + for (uint32_t j = 0; j < m->nslots; j++) { + if (m->bound[j] == 0) continue; + ScrDyn *v = scr_dyn_arr_at(values, j); + if (v == ni_tdz || (m->bound[j] == 1 && m->last[j] != NULL && scr_dyn_strict_eq(v, m->last[j]))) { + scr_dyn_release(v); + continue; + } + ScrNodeValue nv; + bool ok = false; + void *keep = scr_ni_value_out_begin(v, &nv, &ok); + if (ok) { + NiPush a = {i, j, &nv}; + scr_ni_on_node_stack(ni_push_job, &a); + } else { + scr_exc_clear(); + } + scr_ni_value_out_end(keep); + scr_dyn_release(m->last[j]); + m->last[j] = v; + m->bound[j] = 1; + } + scr_dyn_release(values); + } + if (saved != NULL) { + scr_rethrow(saved); + scr_caught_release(saved); + } + ni_refreshing = false; +} + +/* At exit, once Node stopped: everything the export table, the class + * tables and the shared modules hold (the RC audit counts them). */ void scr_ni_exports_release_all(void) { ni_keep_set(NULL); for (uint32_t i = 1; i < ni_exports_len; i++) { @@ -581,8 +917,24 @@ void scr_ni_exports_release_all(void) { free(ni_ids); ni_ids = NULL; ni_ids_cap = ni_ids_len = ni_ids_dead = 0; - for (size_t i = 0; i < ni_nmethods; i++) scr_dyn_release(ni_methods[i].table); + for (size_t i = 0; i < ni_nmethods; i++) { + scr_dyn_release(ni_methods[i].table); + scr_dyn_release(ni_methods[i].ctor); + } ni_nmethods = 0; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + for (uint32_t j = 0; j < m->nslots; j++) scr_dyn_release(m->last[j]); + free(m->last); + free(m->bound); + scr_str_release(m->path); + scr_str_release(m->exports); + scr_dyn_release(m->accessor); + } + ni_nshared = 0; + ni_shared_bound = 0; + scr_dyn_release(ni_tdz); + ni_tdz = NULL; } #endif diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 70c5db4c9..6a113c6ca 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -572,7 +572,10 @@ static bool nh_export_value(ScrDyn *v, ScrNodeValue *out, NhKeep *keep) { scr_ni_export_out(v, out); return true; } - if (v->kind == SCR_DYN_OBJ || v->kind == SCR_DYN_ARR) { + /* Arrays (dyn, and typed array capsules: NI-6 shortcut, as for dyn + * arrays) keep NI-4's JSON copy. */ + if (v->kind == SCR_DYN_OBJ || v->kind == SCR_DYN_ARR || + (v->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(v) != NULL)) { ScrStr *gap = scr_str_new("", 0); ScrDyn *json = scr_json_stringify_replacer(v, scr_dyn_undefined(), gap); scr_str_release(gap); @@ -752,6 +755,7 @@ static void nh_call_job(void *p) { } static int nh_run(NhCall *c) { + scr_ni_shared_refresh(); scr_ni_on_node_stack(nh_call_job, c); return c->threw; } @@ -1136,6 +1140,20 @@ bool scr_ni_value_out(ScrDyn *v, ScrNodeValue *out) { return nh_to_node(v, out, &nh_out_keep); } +/* NI-6: a conversion whose keep belongs to the caller, for values the + * runtime pushes into Node between other conversions (shared modules). */ +void *scr_ni_value_out_begin(ScrDyn *v, ScrNodeValue *out, bool *ok) { + NhKeep *k = calloc(1, sizeof *k); + if (k == NULL) nh_oom(); + *ok = nh_to_node(v, out, k); + return k; +} + +void scr_ni_value_out_end(void *keep) { + nh_keep_done(keep); + free(keep); +} + ScrDyn *scr_ni_value_in(const ScrNodeValue *v) { ScrDyn *d = nh_from_node(v); return d != NULL ? d : scr_dyn_retain(scr_dyn_undefined()); diff --git a/packages/runtime/src/scr_node_island_hooks.cjs b/packages/runtime/src/scr_node_island_hooks.cjs new file mode 100644 index 000000000..b2d7b7496 --- /dev/null +++ b/packages/runtime/src/scr_node_island_hooks.cjs @@ -0,0 +1,146 @@ +// ── shared modules (scope-B NI-6) ────────────────────────────────────── +// The design's hooks.mjs. scripts/node-island-host.mjs appends this file to +// scr_node_island_host.cjs, so it runs in the bootstrap's scope (CommonJS: +// the bootstrap is Node's main script). +// +// A module the program compiled that island code imports must exist once. +// Synchronous, in-thread loader hooks (module.registerHooks) resolve every +// island import or require() of such a module to a generated ES module +// (served at the module's own file URL) whose bindings hold the live native exports: the +// natives sharedGet/sharedTable/sharedTdz ask the runtime directly. An +// export that can change (an exported let, or a binding still in its +// temporal dead zone when the module linked) is bound: the runtime pushes +// each new native value (sharedPush) before island code can observe it. +// +// Temporal dead zone across a cycle: when an island module loads while a +// shared module's bindings are not yet initialized (the native module is +// still evaluating, as in `core` -> island `ext` -> `core`), the load goes +// through an entry module that imports the island module and declares +// those bindings after the import. The generated module re-exports them +// from the entry, so while the island module evaluates they are in their +// temporal dead zone exactly as in Node (V8 throws its own ReferenceError +// with the importer's binding name), and they are pushed once the native +// declarations ran. +// oxlint-disable-next-line no-unused-vars -- called by scr_node_island_host.cjs, which this file is appended to +function scriptcSharedModules(nativeHost) { + const { registerHooks } = require("node:module"); + const { fileURLToPath, pathToFileURL } = require("node:url"); + const { dirname, join } = require("node:path"); + const table = JSON.parse(nativeHost.sharedTable()); + const none = { entry: (path, load) => load(null), push() {} }; + if (table.length === 0) return none; + const byUrl = new Map(); + table.forEach((m, id) => byUrl.set(pathToFileURL(m.path).href, id)); + const TDZ = Symbol("scriptc.tdz"); + const setters = new Map(); // `${mod}:${slot}` -> [(value) => void] + const entries = new Map(); // entry URL -> { target, late: [[mod, slot]] } + let entrySeq = 0; + let current = null; // the entry being loaded + + function addSetter(mod, slot, set) { + const key = `${mod}:${slot}`; + const list = setters.get(key); + if (list === undefined) setters.set(key, [set]); + else list.push(set); + } + + const shared = { + get(mod, slot) { + return nativeHost.sharedGet(mod, slot, 0, TDZ); + }, + bind(mod, slot, set) { + addSetter(mod, slot, set); + return nativeHost.sharedGet(mod, slot, 1, TDZ); + }, + late(mod, slot, set) { + addSetter(mod, slot, set); + const v = nativeHost.sharedGet(mod, slot, 2, TDZ); + return v === TDZ ? undefined : v; + }, + }; + Object.defineProperty(globalThis, Symbol.for("scriptc.shared"), { value: shared }); + + const lateName = (mod, slot) => `__scriptc_${mod}_${slot}`; + + function sharedSource(mod) { + const m = table[mod]; + const lines = ['const __s = globalThis[Symbol.for("scriptc.shared")];']; + const lateIn = current === null ? null : entries.get(current); + m.exports.forEach((e, slot) => { + const name = JSON.stringify(e.name); + const local = `__e${slot}`; + const tdz = nativeHost.sharedGet(mod, slot, 0, TDZ) === TDZ; + if (tdz && lateIn !== null && lateIn.late.some(([a, b]) => a === mod && b === slot)) { + lines.push(`export { ${lateName(mod, slot)} as ${name} } from ${JSON.stringify(current)};`); + } else if (tdz) { + // Reached outside an island load (a lazy require): no entry can hold + // the dead zone; the binding reads undefined until its declaration + // runs (NOTES.md shortcut). + lines.push(`let ${local} = __s.late(${mod}, ${slot}, (v) => { ${local} = v; });`); + lines.push(`export { ${local} as ${name} };`); + } else if (e.kind === "let" || e.kind === "var") { + lines.push(`let ${local} = __s.bind(${mod}, ${slot}, (v) => { ${local} = v; });`); + lines.push(`export { ${local} as ${name} };`); + } else { + lines.push(`const ${local} = __s.get(${mod}, ${slot});`); + lines.push(`export { ${local} as ${name} };`); + } + }); + return lines.join("\n"); + } + + function entrySource(url) { + const e = entries.get(url); + const lines = [`import ${JSON.stringify(e.target)};`]; + lines.push('const __s = globalThis[Symbol.for("scriptc.shared")];'); + for (const [mod, slot] of e.late) { + const n = lateName(mod, slot); + lines.push(`export let ${n} = __s.late(${mod}, ${slot}, (v) => { ${n} = v; });`); + } + return lines.join("\n"); + } + + registerHooks({ + resolve(specifier, context, nextResolve) { + const asUrl = specifier.startsWith("/") ? pathToFileURL(specifier).href : specifier; + if (entries.has(asUrl)) return { url: asUrl, shortCircuit: true }; + const resolved = nextResolve(specifier, context); + // A shared module keeps its own file URL (require() and import key + // their module records alike only for file URLs); load answers it. + if (resolved.url.startsWith("file:") && byUrl.has(resolved.url)) + return { url: resolved.url, format: "module", shortCircuit: true }; + return resolved; + }, + load(url, context, nextLoad) { + const mod = byUrl.get(url); + if (mod !== undefined) + return { format: "module", source: sharedSource(mod), shortCircuit: true }; + if (entries.has(url)) + return { format: "module", source: entrySource(url), shortCircuit: true }; + return nextLoad(url, context); + }, + }); + + return { + /** The entry to load the island module at `path` through, or null when + * no shared binding is in its temporal dead zone. Run `load` with it. */ + entry(path, load) { + const late = JSON.parse(nativeHost.sharedTdz()); + if (late.length === 0) return load(null); + // A file URL beside the island module (never on disk: the load hook + // answers it), so require() and import key it alike. + const url = pathToFileURL(join(dirname(path), `__scriptc_entry_${++entrySeq}.mjs`)).href; + entries.set(url, { target: pathToFileURL(path).href, late }); + const outer = current; + current = url; + try { + return load(fileURLToPath(url)); + } finally { + current = outer; + } + }, + push(mod, slot, value) { + for (const set of setters.get(`${mod}:${slot}`) ?? []) set(value); + }, + }; +} diff --git a/packages/runtime/src/scr_node_island_host.cjs b/packages/runtime/src/scr_node_island_host.cjs index 9d30627e7..8cb89efe7 100644 --- a/packages/runtime/src/scr_node_island_host.cjs +++ b/packages/runtime/src/scr_node_island_host.cjs @@ -127,10 +127,23 @@ function call(id, sync, file, specifier, name, argsJson) { // answers a promise of the namespace. const { pathToFileURL } = require("node:url"); +// Island imports of modules the program shares resolve to their native +// exports (NI-6, scr_node_island_hooks.cjs, appended to this file). +let sharedModules = null; + function load(path, esm) { if (path.startsWith("node:")) return require(path); + if (sharedModules === null) sharedModules = scriptcSharedModules(nativeHost); if (esm) return import(pathToFileURL(path).href); - return createRequire(path)(path); + const load = createRequire(path); + return sharedModules.entry(path, (entry) => { + if (entry !== null) load(entry); + return load(path); + }); +} + +function sharedPush(mod, slot, value) { + if (sharedModules !== null) sharedModules.push(mod, slot, value); } // ── the hosted loop (scope-B NI-3) ───────────────────────────────────── @@ -307,4 +320,5 @@ globalThis.__scriptcNodeIsland = { nextTick, emit, load, + sharedPush, }; diff --git a/packages/runtime/src/scr_node_island_host.h b/packages/runtime/src/scr_node_island_host.h index 12cf217a2..05e47a909 100644 --- a/packages/runtime/src/scr_node_island_host.h +++ b/packages/runtime/src/scr_node_island_host.h @@ -1,4 +1,5 @@ -/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs. +/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs + * and scr_node_island_hooks.cjs. * Do not edit: change the .cjs file and rerun the script. */ #ifndef SCR_NODE_ISLAND_HOST_H #define SCR_NODE_ISLAND_HOST_H @@ -133,10 +134,23 @@ static const char scr_node_island_host_js[] = "// answers a promise of the namespace.\012" "const { pathToFileURL } = require(\"node:url\");\012" "\012" + "// Island imports of modules the program shares resolve to their native\012" + "// exports (NI-6, scr_node_island_hooks.cjs, appended to this file).\012" + "let sharedModules = null;\012" + "\012" "function load(path, esm) {\012" " if (path.startsWith(\"node:\")) return require(path);\012" + " if (sharedModules === null) sharedModules = scriptcSharedModules(nativeHost);\012" " if (esm) return import(pathToFileURL(path).href);\012" - " return createRequire(path)(path);\012" + " const load = createRequire(path);\012" + " return sharedModules.entry(path, (entry) => {\012" + " if (entry !== null) load(entry);\012" + " return load(path);\012" + " });\012" + "}\012" + "\012" + "function sharedPush(mod, slot, value) {\012" + " if (sharedModules !== null) sharedModules.push(mod, slot, value);\012" "}\012" "\012" "// \342\224\200\342\224\200 the hosted loop (scope-B NI-3) \342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\012" @@ -313,6 +327,154 @@ static const char scr_node_island_host_js[] = " nextTick,\012" " emit,\012" " load,\012" - "};\012"; + " sharedPush,\012" + "};\012" + "\012" + "// \342\224\200\342\224\200 shared modules (scope-B NI-6) \342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\012" + "// The design's hooks.mjs. scripts/node-island-host.mjs appends this file to\012" + "// scr_node_island_host.cjs, so it runs in the bootstrap's scope (CommonJS:\012" + "// the bootstrap is Node's main script).\012" + "//\012" + "// A module the program compiled that island code imports must exist once.\012" + "// Synchronous, in-thread loader hooks (module.registerHooks) resolve every\012" + "// island import or require() of such a module to a generated ES module\012" + "// (served at the module's own file URL) whose bindings hold the live native exports: the\012" + "// natives sharedGet/sharedTable/sharedTdz ask the runtime directly. An\012" + "// export that can change (an exported let, or a binding still in its\012" + "// temporal dead zone when the module linked) is bound: the runtime pushes\012" + "// each new native value (sharedPush) before island code can observe it.\012" + "//\012" + "// Temporal dead zone across a cycle: when an island module loads while a\012" + "// shared module's bindings are not yet initialized (the native module is\012" + "// still evaluating, as in `core` -> island `ext` -> `core`), the load goes\012" + "// through an entry module that imports the island module and declares\012" + "// those bindings after the import. The generated module re-exports them\012" + "// from the entry, so while the island module evaluates they are in their\012" + "// temporal dead zone exactly as in Node (V8 throws its own ReferenceError\012" + "// with the importer's binding name), and they are pushed once the native\012" + "// declarations ran.\012" + "// oxlint-disable-next-line no-unused-vars -- called by scr_node_island_host.cjs, which this file is appended to\012" + "function scriptcSharedModules(nativeHost) {\012" + " const { registerHooks } = require(\"node:module\");\012" + " const { fileURLToPath, pathToFileURL } = require(\"node:url\");\012" + " const { dirname, join } = require(\"node:path\");\012" + " const table = JSON.parse(nativeHost.sharedTable());\012" + " const none = { entry: (path, load) => load(null), push() {} };\012" + " if (table.length === 0) return none;\012" + " const byUrl = new Map();\012" + " table.forEach((m, id) => byUrl.set(pathToFileURL(m.path).href, id));\012" + " const TDZ = Symbol(\"scriptc.tdz\");\012" + " const setters = new Map(); // `${mod}:${slot}` -> [(value) => void]\012" + " const entries = new Map(); // entry URL -> { target, late: [[mod, slot]] }\012" + " let entrySeq = 0;\012" + " let current = null; // the entry being loaded\012" + "\012" + " function addSetter(mod, slot, set) {\012" + " const key = `${mod}:${slot}`;\012" + " const list = setters.get(key);\012" + " if (list === undefined) setters.set(key, [set]);\012" + " else list.push(set);\012" + " }\012" + "\012" + " const shared = {\012" + " get(mod, slot) {\012" + " return nativeHost.sharedGet(mod, slot, 0, TDZ);\012" + " },\012" + " bind(mod, slot, set) {\012" + " addSetter(mod, slot, set);\012" + " return nativeHost.sharedGet(mod, slot, 1, TDZ);\012" + " },\012" + " late(mod, slot, set) {\012" + " addSetter(mod, slot, set);\012" + " const v = nativeHost.sharedGet(mod, slot, 2, TDZ);\012" + " return v === TDZ \077 undefined : v;\012" + " },\012" + " };\012" + " Object.defineProperty(globalThis, Symbol.for(\"scriptc.shared\"), { value: shared });\012" + "\012" + " const lateName = (mod, slot) => `__scriptc_${mod}_${slot}`;\012" + "\012" + " function sharedSource(mod) {\012" + " const m = table[mod];\012" + " const lines = ['const __s = globalThis[Symbol.for(\"scriptc.shared\")];'];\012" + " const lateIn = current === null \077 null : entries.get(current);\012" + " m.exports.forEach((e, slot) => {\012" + " const name = JSON.stringify(e.name);\012" + " const local = `__e${slot}`;\012" + " const tdz = nativeHost.sharedGet(mod, slot, 0, TDZ) === TDZ;\012" + " if (tdz && lateIn !== null && lateIn.late.some(([a, b]) => a === mod && b === slot)) {\012" + " lines.push(`export { ${lateName(mod, slot)} as ${name} } from ${JSON.stringify(current)};`);\012" + " } else if (tdz) {\012" + " // Reached outside an island load (a lazy require): no entry can hold\012" + " // the dead zone; the binding reads undefined until its declaration\012" + " // runs (NOTES.md shortcut).\012" + " lines.push(`let ${local} = __s.late(${mod}, ${slot}, (v) => { ${local} = v; });`);\012" + " lines.push(`export { ${local} as ${name} };`);\012" + " } else if (e.kind === \"let\" || e.kind === \"var\") {\012" + " lines.push(`let ${local} = __s.bind(${mod}, ${slot}, (v) => { ${local} = v; });`);\012" + " lines.push(`export { ${local} as ${name} };`);\012" + " } else {\012" + " lines.push(`const ${local} = __s.get(${mod}, ${slot});`);\012" + " lines.push(`export { ${local} as ${name} };`);\012" + " }\012" + " });\012" + " return lines.join(\"\\n\");\012" + " }\012" + "\012" + " function entrySource(url) {\012" + " const e = entries.get(url);\012" + " const lines = [`import ${JSON.stringify(e.target)};`];\012" + " lines.push('const __s = globalThis[Symbol.for(\"scriptc.shared\")];');\012" + " for (const [mod, slot] of e.late) {\012" + " const n = lateName(mod, slot);\012" + " lines.push(`export let ${n} = __s.late(${mod}, ${slot}, (v) => { ${n} = v; });`);\012" + " }\012" + " return lines.join(\"\\n\");\012" + " }\012" + "\012" + " registerHooks({\012" + " resolve(specifier, context, nextResolve) {\012" + " const asUrl = specifier.startsWith(\"/\") \077 pathToFileURL(specifier).href : specifier;\012" + " if (entries.has(asUrl)) return { url: asUrl, shortCircuit: true };\012" + " const resolved = nextResolve(specifier, context);\012" + " // A shared module keeps its own file URL (require() and import key\012" + " // their module records alike only for file URLs); load answers it.\012" + " if (resolved.url.startsWith(\"file:\") && byUrl.has(resolved.url))\012" + " return { url: resolved.url, format: \"module\", shortCircuit: true };\012" + " return resolved;\012" + " },\012" + " load(url, context, nextLoad) {\012" + " const mod = byUrl.get(url);\012" + " if (mod !== undefined)\012" + " return { format: \"module\", source: sharedSource(mod), shortCircuit: true };\012" + " if (entries.has(url))\012" + " return { format: \"module\", source: entrySource(url), shortCircuit: true };\012" + " return nextLoad(url, context);\012" + " },\012" + " });\012" + "\012" + " return {\012" + " /** The entry to load the island module at `path` through, or null when\012" + " * no shared binding is in its temporal dead zone. Run `load` with it. */\012" + " entry(path, load) {\012" + " const late = JSON.parse(nativeHost.sharedTdz());\012" + " if (late.length === 0) return load(null);\012" + " // A file URL beside the island module (never on disk: the load hook\012" + " // answers it), so require() and import key it alike.\012" + " const url = pathToFileURL(join(dirname(path), `__scriptc_entry_${++entrySeq}.mjs`)).href;\012" + " entries.set(url, { target: pathToFileURL(path).href, late });\012" + " const outer = current;\012" + " current = url;\012" + " try {\012" + " return load(fileURLToPath(url));\012" + " } finally {\012" + " current = outer;\012" + " }\012" + " },\012" + " push(mod, slot, value) {\012" + " for (const set of setters.get(`${mod}:${slot}`) \077\077 []) set(value);\012" + " },\012" + " };\012" + "}\012"; #endif /* SCR_NODE_ISLAND_HOST_H */ diff --git a/packages/runtime/test/node-island-host.test.ts b/packages/runtime/test/node-island-host.test.ts index 3fe28697f..8867a161f 100644 --- a/packages/runtime/test/node-island-host.test.ts +++ b/packages/runtime/test/node-island-host.test.ts @@ -8,6 +8,6 @@ const srcDir = join(import.meta.dirname, "../src"); // scr_node_island.c starts `node -e` with the host embedded in this header; // a host edit without regenerating it would ship the old host. test("the embedded Node island host matches its source", () => { - const source = readFileSync(join(srcDir, "scr_node_island_host.cjs"), "utf8"); + const source = `${readFileSync(join(srcDir, "scr_node_island_host.cjs"), "utf8")}\n${readFileSync(join(srcDir, "scr_node_island_hooks.cjs"), "utf8")}`; expect(readFileSync(join(srcDir, "scr_node_island_host.h"), "utf8")).toBe(renderHeader(source)); }); diff --git a/scripts/node-island-host.mjs b/scripts/node-island-host.mjs index 61b93c411..3dadaf26d 100644 --- a/scripts/node-island-host.mjs +++ b/scripts/node-island-host.mjs @@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url"; const srcDir = fileURLToPath(new URL("../packages/runtime/src/", import.meta.url)); const SOURCE = `${srcDir}scr_node_island_host.cjs`; +// The loader hooks of shared modules (NI-6) run in the bootstrap's scope. +const HOOKS = `${srcDir}scr_node_island_hooks.cjs`; const HEADER = `${srcDir}scr_node_island_host.h`; /** One C string literal per source line, every byte outside printable @@ -28,7 +30,8 @@ export function renderHeader(source) { const lines = source.split("\n"); if (lines.at(-1) === "") lines.pop(); const body = lines.map((line) => ` ${cLiteral(`${line}\n`)}`).join("\n"); - return `/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs. + return `/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs + * and scr_node_island_hooks.cjs. * Do not edit: change the .cjs file and rerun the script. */ #ifndef SCR_NODE_ISLAND_HOST_H #define SCR_NODE_ISLAND_HOST_H @@ -41,7 +44,7 @@ ${body}; } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const expected = renderHeader(readFileSync(SOURCE, "utf8")); + const expected = renderHeader(`${readFileSync(SOURCE, "utf8")}\n${readFileSync(HOOKS, "utf8")}`); if (process.argv.includes("--check")) { if (readFileSync(HEADER, "utf8") !== expected) { console.error(`${HEADER} is stale: run node scripts/node-island-host.mjs`); diff --git a/tests/corpus/6850-node-island-shared-singleton/main.mjs b/tests/corpus/6850-node-island-shared-singleton/main.mjs new file mode 100644 index 000000000..97cbe0618 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/main.mjs @@ -0,0 +1,28 @@ +// @requires-libnode +// A module both the program and a Node island import exists once (scope-B +// NI-6): island imports of the compiled `state.mjs` resolve to its live +// native exports, so a write on either side is seen by the other, and an +// exported `let` is a live binding in the island too. +import { requireModule } from "@scriptc/node"; +import { config, count, entries, record, reset } from "./state.mjs"; + +record("main-1"); +console.log("main before island", count, entries()); + +const plugin = requireModule("./plugin.mjs"); +console.log("island start:", plugin.startup); +console.log("main after island load", count, entries()); + +console.log("island report:", plugin.report()); +record("main-2"); +console.log("island report after main write:", plugin.report()); +console.log("island bump:", plugin.bump("island-2"), "main sees", count); + +plugin.configure("island-mode"); +console.log("main sees config", config.mode, config.owner); +config.owner = "main"; +console.log("island sees config", plugin.describeConfig()); + +console.log("same functions", plugin.sameRecord(record), plugin.requireSees()); +reset(); +console.log("after reset", count, entries(), plugin.report()); diff --git a/tests/corpus/6850-node-island-shared-singleton/plugin.mjs b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs new file mode 100644 index 000000000..7078c7097 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs @@ -0,0 +1,37 @@ +// Island code (loaded with requireModule): imports the shared module by +// named imports, by namespace, and through require(). +import { createRequire } from "node:module"; +import * as ns from "./state.mjs"; +import { config, count, entries, record } from "./state.mjs"; + +const require = createRequire(import.meta.url); + +record("island-1"); +export const startup = `count=${count} ns=${ns.count} entries=${entries()}`; + +export function report() { + return `count=${count} ns=${ns.count} entries=${entries()}`; +} + +export function bump(name) { + const before = count; + const after = record(name); + return `${before}->${after} live=${count}`; +} + +export function configure(mode) { + config.mode = mode; +} + +export function describeConfig() { + return `${config.mode}/${config.owner}`; +} + +export function sameRecord(fn) { + return fn === record && fn === ns.record; +} + +export function requireSees() { + const viaRequire = require("./state.mjs"); + return viaRequire === ns && viaRequire.entries() === entries(); +} diff --git a/tests/corpus/6850-node-island-shared-singleton/state.mjs b/tests/corpus/6850-node-island-shared-singleton/state.mjs new file mode 100644 index 000000000..580f522bf --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/state.mjs @@ -0,0 +1,19 @@ +// A stateful singleton: a module-level log and an exported counter. +export let count = 0; +const log = []; +export const config = { mode: "default", owner: "state" }; + +export function record(entry) { + log.push(entry); + count++; + return count; +} + +export function entries() { + return log.join(","); +} + +export function reset() { + log.length = 0; + count = 0; +} diff --git a/tests/corpus/6851-node-island-shared-class/island.mjs b/tests/corpus/6851-node-island-shared-class/island.mjs new file mode 100644 index 000000000..6b36626b6 --- /dev/null +++ b/tests/corpus/6851-node-island-shared-class/island.mjs @@ -0,0 +1,35 @@ +import { Point, isPoint, make, remember, remembered } from "./shapes.mjs"; + +export function check(p) { + return `${p instanceof Point} ${isPoint(p)} ${p.describe()} norm=${p.norm()}`; +} + +export function create(x, y) { + return new Point(x, y); +} + +export function selfCheck() { + const p = new Point(1, 1); + return `${p instanceof Point} ${isPoint(p)} ${make(2, 2) instanceof Point}`; +} + +export function plainIsPoint() { + return `${{ x: 1 } instanceof Point} ${isPoint({ x: 1 })}`; +} + +export function keep(p) { + return remember(p); +} + +export function firstIsNative(p) { + return remembered(0) === p; +} + +export function viaFactory() { + const p = make(5, 12); + return `${p.describe()} ${p.norm()} ${p instanceof Point}`; +} + +export function sameClass(c) { + return c === Point; +} diff --git a/tests/corpus/6851-node-island-shared-class/main.mjs b/tests/corpus/6851-node-island-shared-class/main.mjs new file mode 100644 index 000000000..600b107f9 --- /dev/null +++ b/tests/corpus/6851-node-island-shared-class/main.mjs @@ -0,0 +1,23 @@ +// @requires-libnode +// A class exported by a module both sides import is one class (scope-B +// NI-6): instances made on either side answer instanceof on both, keep +// their identity, and their methods run natively. +import { requireModule } from "@scriptc/node"; +import { Point, describeAll, isPoint, make, remember, remembered } from "./shapes.mjs"; + +const island = requireModule("./island.mjs"); + +const native = new Point(3, 4); +console.log("island sees native instance:", island.check(native)); +const fromIsland = island.create(6, 8); +console.log("native instanceof:", fromIsland instanceof Point, isPoint(fromIsland)); +console.log("native reads:", fromIsland.x, fromIsland.y, fromIsland.norm()); +console.log("island instanceof:", island.selfCheck()); +console.log("plain object (island):", island.plainIsPoint()); +console.log("plain object (native):", isPoint({ x: 1, y: 2 })); + +remember(native); +console.log("kept:", island.keep(fromIsland), describeAll()); +console.log("identity:", remembered(1) === fromIsland, island.firstIsNative(native)); +console.log("made by native factory in island:", island.viaFactory()); +console.log("class identity:", island.sameClass(Point), island.sameClass(make)); diff --git a/tests/corpus/6851-node-island-shared-class/shapes.mjs b/tests/corpus/6851-node-island-shared-class/shapes.mjs new file mode 100644 index 000000000..cdc5dc77d --- /dev/null +++ b/tests/corpus/6851-node-island-shared-class/shapes.mjs @@ -0,0 +1,35 @@ +export class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + norm() { + return Math.hypot(this.x, this.y); + } + describe() { + return `(${this.x},${this.y})`; + } +} + +const kept = []; + +export function remember(p) { + kept.push(p); + return kept.length; +} + +export function remembered(i) { + return kept[i]; +} + +export function describeAll() { + return kept.map((p) => p.describe()).join(" "); +} + +export function make(x, y) { + return new Point(x, y); +} + +export function isPoint(p) { + return p instanceof Point; +} diff --git a/tests/corpus/6852-node-island-shared-cycle/main.mjs b/tests/corpus/6852-node-island-shared-cycle/main.mjs new file mode 100644 index 000000000..adf916dd6 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/main.mjs @@ -0,0 +1,10 @@ +// @requires-libnode +// An import cycle through a Node island (scope-B NI-6): the compiled package +// `core` imports the island package `ext`, which imports `core` back. As in +// Node, `ext` evaluates first and sees core's functions (hoisted) but its +// let and const bindings in their temporal dead zone; once core's body ran, +// the same bindings read core's live values. +import { check, x } from "core"; + +console.log("main:", check()); +console.log("main x:", x); diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts new file mode 100644 index 000000000..4bd83c41b --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts @@ -0,0 +1,5 @@ +export let x: number; +export const y: string; +export function f(): string; +export function bump(): number; +export function check(): string; diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js new file mode 100644 index 000000000..6c6a56662 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js @@ -0,0 +1,16 @@ +import { early, later, bumpFromIsland } from "ext"; + +console.log("core body starts; ext saw:", early); +export let x = 1; +export const y = "y"; +export function f() { + return "f"; +} +export function bump() { + x++; + return x; +} +export function check() { + return `${later()} ${bumpFromIsland()} ${later()}`; +} + diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json new file mode 100644 index 000000000..5bbd96010 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json @@ -0,0 +1 @@ +{ "name": "core", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts new file mode 100644 index 000000000..c081053df --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts @@ -0,0 +1,3 @@ +export const early: string; +export function later(): string; +export function bumpFromIsland(): string; diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js new file mode 100644 index 000000000..efff5b9ab --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js @@ -0,0 +1,19 @@ +import { bump, f, x, y } from "core"; + +console.log("ext evaluates; f() =", f()); +function probe(read) { + try { + return String(read()); + } catch (e) { + return `${e.name}: ${e.message}`; + } +} +export const early = `${probe(() => x)} | ${probe(() => y)}`; + +export function later() { + return `x=${x} y=${y}`; +} + +export function bumpFromIsland() { + return `bump=${bump()} x=${x}`; +} diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json new file mode 100644 index 000000000..1e44518e2 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json @@ -0,0 +1 @@ +{ "name": "ext", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6852-node-island-shared-cycle/package.json b/tests/corpus/6852-node-island-shared-cycle/package.json new file mode 100644 index 000000000..a026108b2 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/package.json @@ -0,0 +1 @@ +{ "name": "shared-cycle", "private": true, "type": "module", "scriptc": { "packages": { "ext": "node" } } } diff --git a/tests/corpus/6853-node-island-shared-registry/main.mjs b/tests/corpus/6853-node-island-shared-registry/main.mjs new file mode 100644 index 000000000..4db2e7100 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/main.mjs @@ -0,0 +1,19 @@ +// @requires-libnode +// The registry scenario of the Node delegation design (§13): a stateful +// package — a module-level Map, an exported `let counter`, a class — that +// the program and a Node island both import. Island code subclasses the +// package's class, so the package is shared Node-owned (scope-B NI-6): one +// instance, its state visible on both sides, live bindings, `require` and +// `import` answering the same module, instanceof both ways, and an island +// subclass whose override and `super` call cross the boundary. +import fs from "node:fs"; +import { requireModule } from "@scriptc/node"; +import * as reg from "registry"; + +const { run } = requireModule("./scenario.mjs"); +const log = (s) => fs.writeSync(1, `${s}\n`); +reg.register(new reg.Plugin("owner-a")); +run(log); +log(`owner list: ${reg.list().join(",")}`); +log(`owner counter: ${reg.counter}`); +log(`owner describeAll: ${reg.describeAll()}`); diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts new file mode 100644 index 000000000..5c8096332 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts @@ -0,0 +1,13 @@ +export let counter: number; +export function bump(): number; +export class Plugin { + name: string; + constructor(name: string); + describe(): string; +} +export function register(p: { name: string; describe(): string }): number; +// A handle (NI-4 copies no island arrays into typed native arrays). +export function list(): any; +export function find(name: string): Plugin | undefined; +export function describeAll(): string; +export function noop(x: T): T; diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js new file mode 100644 index 000000000..e66af40bc --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js @@ -0,0 +1,13 @@ +// A stateful singleton: a plugin registry with a mutable exported binding. +export let counter = 0; +export function bump() { return ++counter; } +const items = new Map(); +export class Plugin { + constructor(name) { this.name = name; } + describe() { return `plugin:${this.name}`; } +} +export function register(p) { items.set(p.name, p); bump(); return items.size; } +export function list() { return [...items.keys()]; } +export function find(name) { return items.get(name); } +export function describeAll() { return [...items.values()].map((p) => p.describe()).join(' | '); } +export function noop(x) { return x; } diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json new file mode 100644 index 000000000..c47a4f064 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json @@ -0,0 +1 @@ +{ "name": "registry", "version": "1.0.0", "type": "module", "exports": { "types": "./index.d.ts", "default": "./index.js" } } diff --git a/tests/corpus/6853-node-island-shared-registry/scenario.mjs b/tests/corpus/6853-node-island-shared-registry/scenario.mjs new file mode 100644 index 000000000..5f503fe61 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/scenario.mjs @@ -0,0 +1,26 @@ +// Island code. Imports the shared module both as ESM (named + namespace) and via require(). +import { createRequire } from 'node:module'; +import * as ns from 'registry'; +import { counter, bump, Plugin, register, list, find, describeAll, noop } from 'registry'; +const require = createRequire(import.meta.url); + +export function run(log) { + log(`island list: ${list().join(',')}`); + log(`island counter: named=${counter} ns=${ns.counter}`); + bump(); + log(`after island bump: named=${counter} ns=${ns.counter}`); + log(`require() sees same instance: ${require('registry').list().join(',')}`); + const p = new Plugin('island-b'); + register(p); + log(`instanceof (island-constructed): ${p instanceof Plugin}`); + log(`instanceof (owner-constructed): ${find('owner-a') instanceof Plugin}`); + log(`find returns same object twice: ${find('owner-a') === find('owner-a')}`); + class Sub extends Plugin { describe() { return `sub(${super.describe()})`; } } + const sub = new Sub('island-sub'); + log(`subclass: proto ok=${Object.getPrototypeOf(sub) === Sub.prototype} describe=${sub.describe()} instanceof Plugin=${sub instanceof Plugin}`); + register(sub); + register({ name: 'island-plain', describe() { return 'plain-from-island'; } }); + log(`describeAll from island: ${describeAll()}`); + log(`Object.keys(owner object): ${Object.keys(find('owner-a') ?? {}).join(',')}`); +} + diff --git a/tests/corpus/6854-node-island-shared-registry-native/main.mjs b/tests/corpus/6854-node-island-shared-registry-native/main.mjs new file mode 100644 index 000000000..9937676d7 --- /dev/null +++ b/tests/corpus/6854-node-island-shared-registry-native/main.mjs @@ -0,0 +1,18 @@ +// @requires-libnode +// The registry scenario of the Node delegation design (§13) with the +// registry as a program module, shared through the loader hooks over its +// native exports (scope-B NI-6): one instance, its state visible on both +// sides, live bindings, `require` and `import` answering the same module, +// instanceof both ways, and island objects in the native registry. (The +// design's island subclass is in 6843, where the registry is Node-owned.) +import fs from "node:fs"; +import { requireModule } from "@scriptc/node"; +import * as reg from "./registry.mjs"; + +const { run } = requireModule("./scenario.mjs"); +const log = (s) => fs.writeSync(1, `${s}\n`); +reg.register(new reg.Plugin("owner-a")); +run(log); +log(`owner list: ${reg.list().join(",")}`); +log(`owner counter: ${reg.counter}`); +log(`owner describeAll: ${reg.describeAll()}`); diff --git a/tests/corpus/6854-node-island-shared-registry-native/registry.mjs b/tests/corpus/6854-node-island-shared-registry-native/registry.mjs new file mode 100644 index 000000000..e66af40bc --- /dev/null +++ b/tests/corpus/6854-node-island-shared-registry-native/registry.mjs @@ -0,0 +1,13 @@ +// A stateful singleton: a plugin registry with a mutable exported binding. +export let counter = 0; +export function bump() { return ++counter; } +const items = new Map(); +export class Plugin { + constructor(name) { this.name = name; } + describe() { return `plugin:${this.name}`; } +} +export function register(p) { items.set(p.name, p); bump(); return items.size; } +export function list() { return [...items.keys()]; } +export function find(name) { return items.get(name); } +export function describeAll() { return [...items.values()].map((p) => p.describe()).join(' | '); } +export function noop(x) { return x; } diff --git a/tests/corpus/6854-node-island-shared-registry-native/scenario.mjs b/tests/corpus/6854-node-island-shared-registry-native/scenario.mjs new file mode 100644 index 000000000..da85c3ec1 --- /dev/null +++ b/tests/corpus/6854-node-island-shared-registry-native/scenario.mjs @@ -0,0 +1,22 @@ +// Island code. Imports the shared module both as ESM (named + namespace) and via require(). +import { createRequire } from 'node:module'; +import * as ns from './registry.mjs'; +import { counter, bump, Plugin, register, list, find, describeAll, noop } from './registry.mjs'; +const require = createRequire(import.meta.url); + +export function run(log) { + log(`island list: ${list().join(',')}`); + log(`island counter: named=${counter} ns=${ns.counter}`); + bump(); + log(`after island bump: named=${counter} ns=${ns.counter}`); + log(`require() sees same instance: ${require('./registry.mjs').list().join(',')}`); + const p = new Plugin('island-b'); + register(p); + log(`instanceof (island-constructed): ${p instanceof Plugin}`); + log(`instanceof (owner-constructed): ${find('owner-a') instanceof Plugin}`); + log(`find returns same object twice: ${find('owner-a') === find('owner-a')}`); + register({ name: 'island-plain', describe() { return 'plain-from-island'; } }); + log(`describeAll from island: ${describeAll()}`); + log(`Object.keys(owner object): ${Object.keys(find('owner-a') ?? {}).join(',')}`); +} + diff --git a/tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json b/tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json b/tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json b/tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json b/tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json b/tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 15de758ca..8d3abcee2 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 6)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 7)\n`, ); }); From ec5f77f508689c35f917ea7fb07fbfbc79955026 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 06:43:02 -0700 Subject: [PATCH 02/12] Give the libnode smoke dispatcher the sharedPush entry the bridge requires ABI 7 takes sharedPush from the bootstrap's dispatcher as well. Co-Authored-By: Claude Opus 5.5 --- packages/libnode-common/smoke/smoke.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/libnode-common/smoke/smoke.c b/packages/libnode-common/smoke/smoke.c index 49d1a90cb..5d5ae8fe0 100644 --- a/packages/libnode-common/smoke/smoke.c +++ b/packages/libnode-common/smoke/smoke.c @@ -46,7 +46,7 @@ static const char kBootstrap[] = " },\n" " setTimer() { return 0; }, clearTimer() {}, refTimer() {}, refreshTimer() {},\n" " setImmediate() { return 0; }, clearImmediate() {}, refImmediate() {},\n" - " nextTick() {}, emit() {}, load() {},\n" + " nextTick() {}, emit() {}, load() {}, sharedPush() {},\n" "};\n"; static double now_ms(void) { From e5792bd554d4c4407b21c9c659b27fab7370dd90 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 02:04:51 -0500 Subject: [PATCH 03/12] 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 73d65b6c1d7c502f568627bd365dc28f81d84b5f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 16:05:38 -0700 Subject: [PATCH 04/12] Move SC8020, SC8022 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 da7791cc2..a40635849 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -115,6 +115,8 @@ export const DIAGNOSTIC_CODES = { SC8006: "Node island modules the build cannot collect", 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", + SC8022: "island code extends a class of a compiled module", SC8030: "type patch: no range covers the installed copy", SC8031: "type patch: selector matches nothing", SC8032: "type patch: declaration already has the type", @@ -153,9 +155,7 @@ export const RETIRED_DIAGNOSTIC_CODES = { * abandoned, delete them here. */ export const RESERVED_DIAGNOSTIC_CODES = { SC8012: "Node islands (ni/* branches)", - SC8020: "Node islands (ni/* branches)", SC8021: "Node islands (ni/* branches)", - SC8022: "Node islands (ni/* branches)", SC8023: "Node islands (ni/* branches)", } as const satisfies Record<`SC${number}`, string>; From 13c18782a610dad8b330b803a648c2fe81f9221a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:14:14 -0700 Subject: [PATCH 05/12] Bind an exported function declaration into island shared modules An exported function declaration is a mutable binding, so its module can replace it; the hooks treated only `let` and `var` exports as live and snapshotted the function, leaving island imports on the first function. Bind function exports like `var`, and cover a reassigned function export in the shared-singleton corpus program. Co-Authored-By: Claude 5.5 --- packages/runtime/src/scr_node_island_hooks.cjs | 7 +++++-- packages/runtime/src/scr_node_island_host.h | 7 +++++-- .../6850-node-island-shared-singleton/main.mjs | 8 +++++++- .../6850-node-island-shared-singleton/plugin.mjs | 6 +++++- .../6850-node-island-shared-singleton/state.mjs | 12 ++++++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/scr_node_island_hooks.cjs b/packages/runtime/src/scr_node_island_hooks.cjs index b2d7b7496..b587132cf 100644 --- a/packages/runtime/src/scr_node_island_hooks.cjs +++ b/packages/runtime/src/scr_node_island_hooks.cjs @@ -8,7 +8,8 @@ // island import or require() of such a module to a generated ES module // (served at the module's own file URL) whose bindings hold the live native exports: the // natives sharedGet/sharedTable/sharedTdz ask the runtime directly. An -// export that can change (an exported let, or a binding still in its +// export that can change (an exported let, var or function declaration, +// whose binding JavaScript allows reassigning, or a binding still in its // temporal dead zone when the module linked) is bound: the runtime pushes // each new native value (sharedPush) before island code can observe it. // @@ -78,7 +79,9 @@ function scriptcSharedModules(nativeHost) { // runs (NOTES.md shortcut). lines.push(`let ${local} = __s.late(${mod}, ${slot}, (v) => { ${local} = v; });`); lines.push(`export { ${local} as ${name} };`); - } else if (e.kind === "let" || e.kind === "var") { + } else if (e.kind === "let" || e.kind === "var" || e.kind === "function") { + // A function declaration's binding is reassignable, like `var`: + // the island must see the current function, not the first one. lines.push(`let ${local} = __s.bind(${mod}, ${slot}, (v) => { ${local} = v; });`); lines.push(`export { ${local} as ${name} };`); } else { diff --git a/packages/runtime/src/scr_node_island_host.h b/packages/runtime/src/scr_node_island_host.h index b481c5ab8..9d1a32282 100644 --- a/packages/runtime/src/scr_node_island_host.h +++ b/packages/runtime/src/scr_node_island_host.h @@ -453,7 +453,8 @@ static const char scr_node_island_host_js[] = "// island import or require() of such a module to a generated ES module\012" "// (served at the module's own file URL) whose bindings hold the live native exports: the\012" "// natives sharedGet/sharedTable/sharedTdz ask the runtime directly. An\012" - "// export that can change (an exported let, or a binding still in its\012" + "// export that can change (an exported let, var or function declaration,\012" + "// whose binding JavaScript allows reassigning, or a binding still in its\012" "// temporal dead zone when the module linked) is bound: the runtime pushes\012" "// each new native value (sharedPush) before island code can observe it.\012" "//\012" @@ -523,7 +524,9 @@ static const char scr_node_island_host_js[] = " // runs (NOTES.md shortcut).\012" " lines.push(`let ${local} = __s.late(${mod}, ${slot}, (v) => { ${local} = v; });`);\012" " lines.push(`export { ${local} as ${name} };`);\012" - " } else if (e.kind === \"let\" || e.kind === \"var\") {\012" + " } else if (e.kind === \"let\" || e.kind === \"var\" || e.kind === \"function\") {\012" + " // A function declaration's binding is reassignable, like `var`:\012" + " // the island must see the current function, not the first one.\012" " lines.push(`let ${local} = __s.bind(${mod}, ${slot}, (v) => { ${local} = v; });`);\012" " lines.push(`export { ${local} as ${name} };`);\012" " } else {\012" diff --git a/tests/corpus/6850-node-island-shared-singleton/main.mjs b/tests/corpus/6850-node-island-shared-singleton/main.mjs index 42fbbb71d..ec3b76256 100644 --- a/tests/corpus/6850-node-island-shared-singleton/main.mjs +++ b/tests/corpus/6850-node-island-shared-singleton/main.mjs @@ -4,7 +4,7 @@ // native exports, so a write on either side is seen by the other, and an // exported `let` is a live binding in the island too. import { requireModule } from "@scriptc/node"; -import { config, count, entries, record, reset } from "./state.mjs"; +import { config, count, entries, record, reset, describe, retitle } from "./state.mjs"; import { bumpTally, tally } from "./tally.mjs"; record("main-1"); @@ -38,3 +38,9 @@ console.log("direct methods", direct.record("direct-2"), direct.record(String(co bumpTally(); const { bumpTally: islandBump } = requireModule("./tally.mjs"); console.log("tally", islandBump(), tally); + +// A reassigned exported function declaration is a live binding too: the +// island sees the function the program installed. +console.log("describe", plugin.describeNow(), describe()); +retitle(); +console.log("retitled", plugin.describeNow(), describe()); diff --git a/tests/corpus/6850-node-island-shared-singleton/plugin.mjs b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs index 7078c7097..3d73ed191 100644 --- a/tests/corpus/6850-node-island-shared-singleton/plugin.mjs +++ b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs @@ -2,7 +2,7 @@ // named imports, by namespace, and through require(). import { createRequire } from "node:module"; import * as ns from "./state.mjs"; -import { config, count, entries, record } from "./state.mjs"; +import { config, count, entries, record, describe } from "./state.mjs"; const require = createRequire(import.meta.url); @@ -35,3 +35,7 @@ export function requireSees() { const viaRequire = require("./state.mjs"); return viaRequire === ns && viaRequire.entries() === entries(); } + +export function describeNow() { + return describe(); +} diff --git a/tests/corpus/6850-node-island-shared-singleton/state.mjs b/tests/corpus/6850-node-island-shared-singleton/state.mjs index 580f522bf..0bbf8989c 100644 --- a/tests/corpus/6850-node-island-shared-singleton/state.mjs +++ b/tests/corpus/6850-node-island-shared-singleton/state.mjs @@ -1,3 +1,5 @@ +// @ts-nocheck -- reassigning the exported function declaration below is the +// behavior under test; a checked file rejects the write. // A stateful singleton: a module-level log and an exported counter. export let count = 0; const log = []; @@ -17,3 +19,13 @@ export function reset() { log.length = 0; count = 0; } + +// A function declaration whose binding this module reassigns: its export +// is live, like a `let` (a function declaration binds like `var`). +export function describe() { + return "first"; +} + +export function retitle() { + describe = () => "second"; +} From efc22a2fd38787181a20a8df0cf042e3bc2cd082 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 17:00:03 -0700 Subject: [PATCH 06/12] Box user module namespaces as dynamic values A `file:` module namespace handed to typed code now boxes as Node's namespace object instead of the "converting typed values to 'unknown'" fence: `const u: unknown = ns`, `take(ns)`, a `Promise` widened to `Promise` (or `Promise | undefined`), and `await import("./m")` in an `unknown` slot all see one non-extensible null-prototype object per module whose own properties are the module's exports in Node's sorted order. They are enumerable read-only accessors whose reads call the compiled namespace getter, so they stay live, and the object carries Symbol.toStringTag "Module", Node's strict-mode write TypeErrors, the not-extensible add error and the ToPrimitive throw. `node:` builtins have no compiled export table and keep their SC1101 fence. The object is built by a new gated runtime unit, packages/runtime/src/scr_module_namespace_dyn.c, selected by the `moduleNamespaceDyn` runtime feature (`module.namespaceDyn` in the IR). It adds no field to ScrDynHandleOps and no branch to a shared unit: the core reaches it through the ordinary SCR_DYN_OBJ paths, so key listing, for-in, `in`, JSON, identity, property reads and assignment all reuse the existing dispatch. The compiler reuses the getter lifted for module.namespaceValue (existing programs' IR is unchanged) and adds moduleNamespacePromiseDynValue, one lifted async mapper per module for the promise slots. Zero cost: a program that never boxes a namespace links exactly what it did. The zero-cost gate reports every program identical, with byte-identical IR and LLVM; the runtime pack's only difference is the new unit (0 changed runtime functions), and among the namespace corpus programs only 8412 links it. Evidence: tests/corpus/8412-module-namespace-untyped is a differential program against Node in both the plain and sanitized lanes, and the diagnostics snapshots and TypeScript 7 baselines stay unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/native-link-info-core.ts | 3 + .../compiler/src/backend/native/contracts.ts | 3 + .../compiler/src/backend/native/executable.ts | 2 + .../compiler/src/backend/runtime-pack-core.ts | 2 + .../src/executable/executable-cache.ts | 1 + .../compiler/src/frontend/lowering/lowerer.ts | 105 ++++++++++++- .../lowering/module-namespace-elements.ts | 108 +++++++++++-- packages/compiler/src/index.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 + packages/compiler/src/ir/runtime-features.ts | 3 + .../compiler/src/native/sanitizer.test.ts | 1 + .../main.ts.json | 7 + .../runtime-pack-matrix.mjs | 2 + packages/runtime/src/scr_blob.h | 7 + .../runtime/src/scr_module_namespace_dyn.c | 143 ++++++++++++++++++ .../8412-module-namespace-untyped/main.ts | 70 +++++++++ .../8412-module-namespace-untyped/mod.ts | 12 ++ .../main.ts.json | 1 + 20 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json create mode 100644 packages/runtime/src/scr_module_namespace_dyn.c create mode 100644 tests/corpus/8412-module-namespace-untyped/main.ts create mode 100644 tests/corpus/8412-module-namespace-untyped/mod.ts create mode 100644 tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 73bce21b6..7df8d7d3c 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -22,6 +22,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), + ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 185c85638..dc3a4f108 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -305,6 +305,7 @@ export const LIB_FN_SYMS: Record = { "process.builtinModule": "scr_process_builtin_module", "process.builtinUnsupported": "scr_process_builtin_unsupported", "module.namespaceValue": "scr_module_namespace_value", + "module.namespaceDyn": "scr_module_namespace_dyn_value", "fs.callbackValue": "scr_fs_callback_value", "fs.callbackCall": "scr_fs_callback_call", "process.hrtimeValue": "scr_process_hrtime_value", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 49681c986..6c282b74d 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -40,6 +40,9 @@ export interface NativeLinkFeatures { /** Module namespace objects as checked-dynamic values link * scr_module_namespace.c; absent (false) otherwise. */ moduleNamespace?: boolean; + /** A module namespace boxed for typed code links + * scr_module_namespace_dyn.c; absent (false) otherwise. */ + moduleNamespaceDyn?: boolean; zlib: boolean; /** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */ brotli?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index ba475be61..370bde77e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -164,6 +164,9 @@ export interface CcOptions { /** Module namespace objects as checked-dynamic values: compiles * scr_module_namespace.c. */ moduleNamespace?: boolean; + /** A module namespace boxed for typed code: compiles + * scr_module_namespace_dyn.c. */ + moduleNamespaceDyn?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 1d9f24f03..98140888b 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -202,6 +202,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.wasm, "scr_wasm.c"), // Module namespace objects as checked-dynamic values. unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"), + // A module namespace as the frozen null-prototype object typed code sees. + unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"), unit( (u) => u.opts.dynAsync || diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index af4c9f874..a4884300c 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -103,6 +103,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; + moduleNamespaceDyn: boolean; nativeFetch: boolean; abortSignalOnly: boolean; netEffective: boolean; @@ -312,6 +313,7 @@ export function effectiveRuntimeFeatures( nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, + moduleNamespaceDyn: features.moduleNamespaceDyn === true, nativeFetch, // The fetch unit has AbortController and AbortSignal; scr_abort.c is // the same implementation without the fetch transport. diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index a7f123860..bc43e2d39 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -67,6 +67,7 @@ export interface EarlyExecutableNativeFeatures { stdioExtended?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; + moduleNamespaceDyn?: boolean; zlib: boolean; brotli?: boolean; assert: boolean; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 4c521386f..92a84c5f1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -41,7 +41,11 @@ import { type UnitEvents, type WaveHost, } from "./unit-journal.js"; -import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js"; +import { + moduleNamespaceDynValue, + moduleNamespaceObject, + moduleNamespaceValue, +} from "./module-namespace-elements.js"; import { keyedHelperName, moduleTagNumber, portableRelativePath } from "./stable-names.js"; import { positionTag, StructuralPositions } from "./structural-positions.js"; import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; @@ -189,6 +193,7 @@ import { captureContextBinding, declareContextLocal, declareContextThis, + newFnCtx, type FnCtx, } from "./function-context.js"; import type { FrontendServices } from "../services.js"; @@ -5120,6 +5125,79 @@ export class Lowerer { return this.moduleOrder.find((sf) => tsgoPath(resolve(sf.fileName)) === path) ?? null; } + /** A module namespace taken as a checked-dynamic value (`const u: unknown + * = ns`, `take(ns)`): the runtime's namespace object — one per module, + * Node's sorted export keys, live reads through a compiled getter, and + * Node's read-only write errors — built by `moduleNamespaceDynValue`. + * `node:` builtins have no compiled source and no complete export table, + * so they keep their fence. */ + moduleNamespaceDyn(ns: IrExpr & { type: IrType & { kind: "moduleNs" } }): IrExpr | null { + if (!ns.type.moduleId.startsWith("file:")) return null; + return moduleNamespaceDynValue(this, ns); + } + + /** A `Promise` taken as `Promise` (or as + * `unknown`): one lifted async mapper per module awaits the promise and + * answers the same namespace object a bare namespace value boxes to, so + * the settlement is Node's namespace while the value stays a promise. + * The import() promise reaches these slots alone, and the runtime's + * per-module namespace cache keeps `await mapped === await original`; a + * rejection propagates unchanged. */ + moduleNamespacePromiseDynValue( + value: IrExpr & { type: IrType & { kind: "promise" } }, + ): IrExpr | null { + const inner = value.type.inner; + if (inner.kind !== "moduleNs" || !inner.moduleId.startsWith("file:")) return null; + const loc = value.loc; + const name = `%module.promise:${inner.moduleId}`; + const promiseT: IrType & { kind: "promise" } = { kind: "promise", inner }; + if (!this.liftedFns.some((fn) => fn.name === name)) { + const context = newFnCtx(false, null, null, DYN); + context.isAsync = true; + const previousClass = this.currentClass; + this.currentClass = null; + this.fnStack.push(context); + let namespace: IrExpr | null = null; + try { + const awaited: IrExpr & { type: IrType & { kind: "moduleNs" } } = { + kind: "awaitExpr", + value: varRef("promise", promiseT, loc), + type: inner, + loc, + }; + namespace = moduleNamespaceDynValue(this, awaited); + if (namespace) { + const fn: IrFunction = { + name, + params: [{ localId: "promise", name: "promise", type: promiseT }], + returnType: DYN, + locals: [ + { id: "promise", name: "promise", type: promiseT, mutable: false }, + ...context.locals, + ], + body: [{ kind: "return", value: namespace, loc }], + async: true, + loc, + }; + this.liftedFns.push(fn); + } + } finally { + this.fnStack.pop(); + this.currentClass = previousClass; + } + // A namespace with no compiled source has no dynamic value: keep the + // promise unconverted so the destination's own fence reports it. + if (!namespace) return null; + } + return { + kind: "call", + callee: name, + args: [value], + type: { kind: "promise", inner: DYN }, + loc, + }; + } + builtinErrorInfoOf(symbol: ts.Symbol | null | undefined): ClassInfo | null { return builtinErrorInfoOf(this, symbol); } @@ -11869,6 +11947,21 @@ export class Lowerer { const result = this.coerceToExpected(expr.result, expected); return { ...expr, result, type: result.type }; } + // A `Promise` widening into `Promise`, or + // into an `unknown` slot: map the payload through the namespace's + // dynamic value (moduleNamespacePromiseDynValue). A destination that + // wants the namespace type itself keeps the promise unchanged; the + // union path reuses this through its single-promise-arm conversion. + if ( + expr.type.kind === "promise" && + expr.type.inner.kind === "moduleNs" && + (expected.kind === "dyn" || (expected.kind === "promise" && expected.inner.kind === "dyn")) + ) { + const mapped = this.moduleNamespacePromiseDynValue( + expr as IrExpr & { type: IrType & { kind: "promise" } }, + ); + if (mapped) expr = mapped; + } // An instance of a class declared `extends Array` is its array (the // same object); an array slot of another element type converts it as // an array. @@ -12016,6 +12109,16 @@ export class Lowerer { const object = moduleNamespaceObject(this, ns) ?? moduleNamespaceValue(this, ns); if (object) return object; } + // A TypeScript source's module namespace as an untyped value: the + // runtime's namespace object with live reads, Node's sorted keys and + // read-only writes. `node:` builtins have no compiled source or + // complete export table here and keep their fence below. + if (expr.type.kind === "moduleNs") { + const boxed = this.moduleNamespaceDyn( + expr as IrExpr & { type: IrType & { kind: "moduleNs" } }, + ); + if (boxed) return boxed; + } if ( expr.type.kind === "moduleNs" && expr.type.moduleId === "builtin:events" && diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index 2c8e6a475..be94aa34a 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -4,6 +4,7 @@ import * as ts from "../ts7/adapter.js"; import { DYN, STRING, + SYMBOL_T, type IrExpr, type IrFunction, type IrStmt, @@ -173,18 +174,14 @@ export function moduleNamespaceObject( }; } -/** A module namespace taken as an untyped value whose exports a copy - * cannot represent: a binding that can change after initialization (`let`, - * `var`), or an export the copy cannot name. The value is the runtime's - * namespace object (scr_module_namespace_value): one per module, whose - * property reads call a compiled getter for the export's current value and - * whose writes throw Node's TypeError. An export whose value has no - * checked-dynamic form throws its fence when it is read, and listing the - * object's keys or inspecting it throws the "not supported yet" error. */ -export function moduleNamespaceValue( +/** The compiled getter behind a namespace object: one lifted function per + * module that answers an export's current value by name, plus the module's + * sorted export names. An export whose value has no checked-dynamic form + * throws its fence when it is read. */ +function moduleNamespaceGetter( lowerer: Lowerer, ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, -): IrExpr | null { +): { name: string; keys: string[] } | null { const source = lowerer.sourceFileOfModuleNamespace(ns.type); if (!source) return null; const symbol = lowerer.checker.getSymbolAtLocation(source); @@ -263,6 +260,24 @@ export function moduleNamespaceValue( lowerer.currentClass = previousClass; } } + return { name, keys: exports.map(({ key }) => key) }; +} + +/** A module namespace taken as an untyped value whose exports a copy + * cannot represent: a binding that can change after initialization (`let`, + * `var`), or an export the copy cannot name. The value is the runtime's + * namespace object (scr_module_namespace_value): one per module, whose + * property reads call a compiled getter for the export's current value and + * whose writes throw Node's TypeError. An export whose value has no + * checked-dynamic form throws its fence when it is read, and listing the + * object's keys or inspecting it throws the "not supported yet" error. */ +export function moduleNamespaceValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const getter = moduleNamespaceGetter(lowerer, ns); + if (!getter) return null; + const loc = ns.loc; const value: IrExpr = { kind: "libCall", fn: "module.namespaceValue", @@ -270,13 +285,82 @@ export function moduleNamespaceValue( strLit(ns.type.moduleId, loc), { kind: "arrayLit", - elems: exports.map(({ key }) => strLit(key, loc)), + elems: getter.keys.map((key) => strLit(key, loc)), type: arrayOf(STRING), loc, }, { kind: "dynFrom", - value: { kind: "closure", fnName: name, captures: [], type: funcOf([STRING], DYN), loc }, + value: { + kind: "closure", + fnName: getter.name, + captures: [], + type: funcOf([STRING], DYN), + loc, + }, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + if (ns.kind === "moduleNsRef") return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: ns, loc }], + result: value, + type: DYN, + loc, + }; +} + +/** A module namespace boxed for typed code through a dynamic slot + * (`const u: unknown = ns`, `take(ns)`): Node's namespace object as + * scr_module_namespace_dyn.c builds it — one non-extensible null-prototype + * object per module whose own sorted export names are enumerable read-only + * accessors, whose `Symbol.toStringTag` is "Module", whose reads call the same + * compiled getter, and whose writes and coerces throw Node's TypeErrors. A + * `node:` builtin has no compiled export table, so it keeps its fence. */ +export function moduleNamespaceDynValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const getter = moduleNamespaceGetter(lowerer, ns); + if (!getter) return null; + const loc = ns.loc; + const value: IrExpr = { + kind: "libCall", + fn: "module.namespaceDyn", + args: [ + strLit(ns.type.moduleId, loc), + { + kind: "arrayLit", + elems: getter.keys.map((key) => strLit(key, loc)), + type: arrayOf(STRING), + loc, + }, + { + kind: "dynFrom", + value: { + kind: "closure", + fnName: getter.name, + captures: [], + type: funcOf([STRING], DYN), + loc, + }, + type: DYN, + loc, + }, + { + kind: "dynFrom", + value: { + kind: "libCall", + fn: "sym.wellKnown", + args: [strLit("toStringTag", loc)], + type: SYMBOL_T, + loc, + }, type: DYN, loc, }, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 90b062580..d9d78bf77 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -695,6 +695,7 @@ async function compileExecutableNative( ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), + ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 06ddb5007..a3cf4252c 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3577,6 +3577,11 @@ function buildLibFnSignatures() { * (scr_module_namespace_value): the module id, its sorted export names * and the compiled getter. One object per module. Never throws. */ "module.namespaceValue": { argTypes: [STRING, arrayOf(STRING), DYN], result: DYN }, + /** A module namespace as typed code sees it through a dynamic slot + * (scr_module_namespace_dyn_value): the module id, its sorted export + * names, the compiled getter, and the Symbol.toStringTag key. Draws + * Node's frozen null-prototype object, one per module. Never throws. */ + "module.namespaceDyn": { argTypes: [STRING, arrayOf(STRING), DYN, DYN], result: DYN }, "fs.callbackValue": { argTypes: [STRING], result: DYN }, "fs.callbackCall": { argTypes: [STRING, DYN], result: DYN }, "process.hrtimeValue": { argTypes: [], result: DYN }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 04392fe48..6a73d1e0a 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1193,6 +1193,8 @@ const RUNTIME_FEATURE_RULES = { /** Module namespace objects as checked-dynamic values * (scr_module_namespace.c). */ moduleNamespace: rule({ fns: ["module.namespaceValue"] }), + /** A module namespace boxed for typed code (scr_module_namespace_dyn.c). */ + moduleNamespaceDyn: rule({ fns: ["module.namespaceDyn"] }), /** The WebAssembly API (scr_wasm.c). */ wasm: rule({ prefixes: ["wasm."] }), /** crypto.subtle importKey/sign/verify/digest and CryptoKey: @@ -1292,6 +1294,7 @@ function noRuntimeFeatures(): RuntimeFeatures { urlIdna: false, stdioExtended: false, moduleNamespace: false, + moduleNamespaceDyn: false, stream: false, streamDyn: false, handleProps: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index f66381659..1a1262490 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -34,6 +34,7 @@ const features = { nodeIsland: false, brotli: false, moduleNamespace: false, + moduleNamespaceDyn: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json new file mode 100644 index 000000000..9384505a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8412-module-namespace-untyped/mod.ts", + "/tests/corpus/8412-module-namespace-untyped/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index e26fdb8d8..d14f5ae88 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -90,6 +90,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_perf_loop.c", "perfLoop"], // Module namespace objects as checked-dynamic values. ["scr_module_namespace.c", "moduleNamespace"], + // The same namespace as the frozen null-prototype object typed code sees. + ["scr_module_namespace_dyn.c", "moduleNamespaceDyn"], // Blob and File box their reads as dyn promises through scr_async_dyn.c. ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 0882e41b6..7c7893322 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -34,6 +34,13 @@ ScrDyn *scr_process_builtin_unsupported(ScrStr *id, ScrStr *member); * one handle per module `id`, whose reads call `getter` with the export * name when `keys` lists it. Borrows all three; +1 handle. */ ScrDyn *scr_module_namespace_value(ScrStr *id, ScrArr *keys, ScrDyn *getter); +/* A module namespace as checked-dynamic code sees it + * (scr_module_namespace_dyn.c): one non-extensible null-prototype object per + * module `id`, Node's sorted export names as enumerable read-only accessors + * whose getters call `getter` with the name, `Symbol.toStringTag` "Module" + * (the well-known symbol is `tag_key`), and Node's strict-mode write + * TypeErrors. Borrows all four; +1 object. */ +ScrDyn *scr_module_namespace_dyn_value(ScrStr *id, ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key); ScrDyn *scr_global_native(ScrArr *known); ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)); ScrDyn *scr_console_native(void); diff --git a/packages/runtime/src/scr_module_namespace_dyn.c b/packages/runtime/src/scr_module_namespace_dyn.c new file mode 100644 index 000000000..632a09905 --- /dev/null +++ b/packages/runtime/src/scr_module_namespace_dyn.c @@ -0,0 +1,143 @@ +/* Module namespace objects handed to typed code as checked-dynamic values. + * + * In Node a module namespace is a non-extensible null-prototype object whose + * own properties are the module's exports in sorted order, which are + * enumerable and read-only, whose Symbol.toStringTag is "Module", and whose + * ToPrimitive finds no method. Every generic dynamic operation — + * Object.keys/values/entries/getOwnPropertyNames, for-in, `in`, + * JSON.stringify, identity, member reads and calls — already handles that + * shape through scr_json.c's object + * dispatch, so this unit only builds the object and links only into programs + * that box a namespace. + * + * The exports stay live: each property is an accessor whose getter calls the + * compiled namespace getter with the export name (the same getter the + * JavaScript-source representation uses), and whose setter throws Node's + * strict-mode read-only TypeError. An unknown name is not a member, so the + * object's own not-extensible state answers Node's "Cannot add property". + * One object per module keeps its identity. + * + * Node models the exports as writable data properties on a sealed object, so + * Object.isFrozen answers false and Object.isSealed true. Live reads need + * accessors here, and the accessor shape cannot be sealed without also + * freezing: Object.isFrozen and Object.isExtensible answer as Node's do, + * Object.isSealed does not. */ +#include "scr_runtime.h" + +#include +#include + +typedef struct ScrModuleNamespaceDyn { + ScrStr *id; + ScrDyn *object; /* retained for the program's lifetime */ + struct ScrModuleNamespaceDyn *next; +} ScrModuleNamespaceDyn; + +static SCR_TL ScrModuleNamespaceDyn *scr_module_namespace_dyns; +static SCR_TL bool scr_module_namespace_dyn_cleanup_registered; + +static void scr_module_namespace_dyn_cleanup(void) { + while (scr_module_namespace_dyns) { + ScrModuleNamespaceDyn *ns = scr_module_namespace_dyns; + scr_module_namespace_dyns = ns->next; + scr_str_release(ns->id); + scr_dyn_release(ns->object); + free(ns); + } +} + +/* An export's current value: the compiled getter takes the export name as a + * dyn string and answers it +1. */ +static ScrDyn *scr_module_namespace_dyn_read(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *getter = scr_box_get_ref(clo->caps[0]); + ScrDyn *name = scr_box_get_ref(clo->caps[1]); + ScrDyn *argument = scr_dyn_retain(name); + ScrDyn *value = scr_dyn_call(getter, &argument, 1, "module namespace export"); + scr_dyn_release(argument); + scr_dyn_release(name); + scr_dyn_release(getter); + return value; +} + +/* A namespace export is read-only: Node's strict-mode TypeError names the + * export and the namespace's [object Module] tag. */ +static ScrDyn *scr_module_namespace_dyn_write(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *name = scr_box_get_ref(clo->caps[0]); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Cannot assign to read only property '"); + scr_jb_write(&text, name->v.str->data, name->v.str->len); + scr_jb_puts(&text, "' of object '[object Module]'"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + scr_dyn_release(name); + return NULL; +} + +/* `thunk` as a dynamic function over one or two captured dynamic values. */ +static ScrDyn *scr_module_namespace_dyn_fn(ScrDynThunk thunk, ScrDyn *first, ScrDyn *second) { + ScrClosure *closure = scr_closure_new(NULL, second ? 2 : 1); + closure->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[0], scr_dyn_retain(first)); + if (second) { + closure->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[1], scr_dyn_retain(second)); + } + return scr_dyn_new_func(closure, thunk, 0, "native:module namespace", "module namespace export"); +} + +/* The namespace object: null prototype, one enumerable read-only accessor per + * export in `keys` order, a non-writable Symbol.toStringTag of "Module", and + * not extensible. */ +static ScrDyn *scr_module_namespace_dyn_build(ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key) { + ScrDyn *object = scr_dyn_new_obj_null_proto(); + for (size_t i = 0; i < keys->len; i++) { + ScrStr *key = scr_arr_get_ref(keys, (double)i); /* +1 */ + ScrDyn *name = scr_dyn_new_str(key); + ScrDyn *read = scr_module_namespace_dyn_fn(scr_module_namespace_dyn_read, getter, name); + ScrDyn *write = scr_module_namespace_dyn_fn(scr_module_namespace_dyn_write, name, NULL); + scr_dyn_obj_set_accessor(object, key->data, key->len, read, write); + scr_dyn_release(name); + scr_str_release(key); + } + ScrStr *tag = scr_str_new("Module", 6); + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "value", 5, scr_dyn_new_str(tag)); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(false)); + scr_dyn_release(scr_dyn_define_property(object, tag_key, descriptor)); + scr_str_release(tag); + scr_dyn_release(descriptor); + scr_dyn_release(scr_dyn_prevent_extensions(object)); + if (scr_exc_pending()) { + /* The tag key is a well-known symbol, so this cannot fail in practice; a + * pending exception still must not be cached. */ + scr_dyn_release(object); + return NULL; + } + return object; +} + +ScrDyn *scr_module_namespace_dyn_value(ScrStr *id, ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key) { + for (ScrModuleNamespaceDyn *ns = scr_module_namespace_dyns; ns; ns = ns->next) { + if (ns->id->len == id->len && memcmp(ns->id->data, id->data, id->len) == 0) + return scr_dyn_retain(ns->object); + } + if (!scr_module_namespace_dyn_cleanup_registered) { + scr_atexit(scr_module_namespace_dyn_cleanup); + scr_module_namespace_dyn_cleanup_registered = true; + } + ScrDyn *object = scr_module_namespace_dyn_build(keys, getter, tag_key); + if (!object) return NULL; + ScrModuleNamespaceDyn *ns = malloc(sizeof(*ns)); + if (!ns) scr_trap("scriptc: out of memory\n"); + *ns = (ScrModuleNamespaceDyn){ scr_str_retain(id), scr_dyn_retain(object), scr_module_namespace_dyns }; + scr_module_namespace_dyns = ns; + return object; +} diff --git a/tests/corpus/8412-module-namespace-untyped/main.ts b/tests/corpus/8412-module-namespace-untyped/main.ts new file mode 100644 index 000000000..d569800e6 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/main.ts @@ -0,0 +1,70 @@ +// A module namespace taken as a checked-dynamic value: Node's export keys, +// live reads, function typeof, member calls, `in`, for-in, JSON, the +// [object Module] tag, the read-only write TypeErrors and the "Cannot +// convert object to primitive value" coercion, plus `Promise` +// widening into `Promise` and `unknown`. +import * as declared from "./mod.ts"; + +function keys(value: unknown): string { + return Object.keys(value as any).join(","); +} + +function describe(value: unknown): void { + const dyn = value as any; + console.log("keys", keys(value)); + console.log("kinds", valueKinds(value)); + console.log("names", Object.getOwnPropertyNames(dyn).join(",")); + console.log("alpha", dyn.alpha, typeof dyn.beta, typeof dyn.default); + console.log("call", dyn.beta()); + console.log("tag", Object.prototype.toString.call(dyn)); + console.log("in", "alpha" in dyn, "toString" in dyn); + const forIn: string[] = []; + for (const key in dyn) forIn.push(key); + console.log("for-in", forIn.join(",")); + console.log("json", JSON.stringify(dyn)); + try { + console.log("string", String(dyn)); + } catch (error) { + console.log("string threw", (error as Error).message); + } + try { + dyn.alpha = 2; + console.log("write", dyn.alpha); + } catch (error) { + console.log("write threw", (error as Error).message); + } + try { + dyn.gamma = 2; + console.log("add", dyn.gamma); + } catch (error) { + console.log("add threw", (error as Error).message); + } + console.log("live", dyn.alpha); +} + +function valueKinds(value: unknown): string { + const dyn = value as any; + const kinds: string[] = []; + for (const key of Object.keys(dyn)) kinds.push(typeof dyn[key]); + return kinds.join(","); +} + +function bump(value: unknown): void { + const dyn = value as any; + dyn.bump(); + console.log("after bump", dyn.count); +} + +async function main(): Promise { + describe(declared); + bump(declared); + const loaded = await import("./mod.ts"); + console.log("same", loaded === declared); + describe(loaded); + const widened: Promise | undefined = import("./mod.ts"); + if (widened) console.log("widened", keys(await widened)); + const boxed: unknown = import("./mod.ts"); + console.log("unknown", keys(await boxed)); +} + +main(); diff --git a/tests/corpus/8412-module-namespace-untyped/mod.ts b/tests/corpus/8412-module-namespace-untyped/mod.ts new file mode 100644 index 000000000..be7be7d4a --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/mod.ts @@ -0,0 +1,12 @@ +export const alpha = 1; +export let count = 0; +export function beta(): string { + return "b" + String(count); +} +export function bump(): void { + count += 1; +} +export const obj = { k: alpha }; +export default class Gamma { + readonly tag = "g"; +} diff --git a/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json b/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json new file mode 100644 index 000000000..6a0493eb5 --- /dev/null +++ b/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4451, "san": 57918 } From 4a673558b946326899c9368bd67726983be2e9cf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 05:38:33 -0700 Subject: [PATCH 07/12] Journal the shared-module plan fields in the lowerer The unit-journal test merged in from main requires every lowerer field to be journaled or named as unjournaled, and the NI-6 fields were neither: `sharedModulePlans` runs inside the first unit that lowers a module, so journal the warning count like `diags`, and name the plan memo and the per-statement TDZ flags it fills as unjournaled. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lowerer.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c81e34e7d..3cef81c5c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4096,6 +4096,10 @@ export class Lowerer { setTable("freezableArrays", this.freezableArrays), checkedTable("moduleOrder", () => this.moduleOrder.length), checkedTable("diags", () => this.diags.length), + // The shared-module plan runs inside the first unit that lowers a + // module; a warning it adds is a diagnostic of that unit, so its count + // is journaled like `diags` (the plan itself is unjournaled). + checkedTable("sharedModuleWarnings", () => this.sharedModuleWarnings.length), // Node island call sites hold literals of the unit's IR that lowering's // end rewrites to module tree paths (nodeIslandRecord): a unit that adds // one lowers again rather than replaying a record. @@ -4370,6 +4374,11 @@ export class Lowerer { "argumentCountSlots", "coercions.widenedReturnTypes", "packageFileMemo", + // The modules shared with the islands (NI-6): a pure function of the + // program's modules, computed once, and the TDZ flags its plan gives a + // statement. + "sharedModules", + "sharedInitFlags", "slotOriginMemo", "recordOriginMemo", "boundaryCastOrder", From dea433dacd1828f17c1bc76b3e15219b3b1b4df9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 10:21:05 -0500 Subject: [PATCH 08/12] Call the receiver copy for direct calls whose result is discarded A direct call to an untyped method that returns `this` on every path, used as a statement (`builder.add(part);`), boxed its receiver into a capsule that nothing read. Such a call now runs the method's copy that returns nothing, as the class dispatch helpers already do. --- .../src/frontend/lowering/receiver-returns.ts | 20 +++++++- ...ynamic-method-receiver-statements.mjs.json | 6 +++ .../dynamic-method-receiver-statements.mjs | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json create mode 100644 tests/corpus/dynamic-method-receiver-statements.mjs diff --git a/packages/compiler/src/frontend/lowering/receiver-returns.ts b/packages/compiler/src/frontend/lowering/receiver-returns.ts index 5e83f236a..d1c160d7f 100644 --- a/packages/compiler/src/frontend/lowering/receiver-returns.ts +++ b/packages/compiler/src/frontend/lowering/receiver-returns.ts @@ -7,8 +7,10 @@ * (`%dyn.class.call.N`) that calls such a method already holds a capsule * of the same instance at its exact class: its receiver after * normalization. The helper now calls a copy of the method that returns - * nothing and answers its own receiver, so the call boxes nothing. Other - * callers (vtables, typed code, method values) keep the method itself. */ + * nothing and answers its own receiver, so the call boxes nothing. A + * direct call whose result nothing reads calls the copy too. Other callers + * (vtables, method values, calls that use the result) keep the method + * itself. */ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { VOID } from "../../ir/ir.js"; import { varRef } from "../../ir/build.js"; @@ -57,6 +59,20 @@ export function returnDispatchReceivers(lowerer: Lowerer, functions: IrFunction[ ]; } } + // A direct call whose result nothing reads (`builder.set(1);` in typed + // code) needs no capsule at all. + for (const fn of [...functions]) { + everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "exprStmt" && s.expr.kind === "call") { + const copy = copyOf(s.expr.callee); + if (copy) s.expr = { ...s.expr, callee: copy.name, type: VOID }; + } + return true; + }, + expr: () => true, + }); + } } /** The helper's receiver is assigned only by its normalization, so it is diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json new file mode 100644 index 000000000..97d131a90 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-method-receiver-statements.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/dynamic-method-receiver-statements.mjs b/tests/corpus/dynamic-method-receiver-statements.mjs new file mode 100644 index 000000000..80c125475 --- /dev/null +++ b/tests/corpus/dynamic-method-receiver-statements.mjs @@ -0,0 +1,46 @@ +// Untyped methods that return their receiver, called as statements on +// receivers whose class is known: side effects, throws, overrides, and +// results that are used. +class Builder { + constructor() { + this.parts = []; + this.count = 0; + } + add(part) { + if (part === "") throw new TypeError("empty part"); + this.parts.push(part); + this.count++; + return this; + } + reset() { + this.parts.length = 0; + return this; + } +} +class LoudBuilder extends Builder { + add(part) { + super.add(part.toUpperCase()); + return this; + } +} +const b = new Builder(); +for (let i = 0; i < 1000; i++) { + b.add("p" + (i % 3)); + if (i % 100 === 99) b.reset(); +} +console.log(b.count, b.parts.join(",")); +b.add("x").add("y"); +const same = b.add("z"); +console.log(same === b, b.parts.join(",")); +try { + b.add(""); +} catch (e) { + console.log(e instanceof TypeError, e.message, b.count); +} +const loud = new LoudBuilder(); +loud.add("a"); +loud.add("b").add("c"); +console.log(loud.parts.join(","), loud.count, loud instanceof Builder); +const builders = [new Builder(), new LoudBuilder()]; +for (const each of builders) each.add("q"); +console.log(builders.map((each) => each.parts.join("")).join("|")); From a0849484ab8650101974cde4857fdd030b2f817d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 09:09:45 -0700 Subject: [PATCH 09/12] Read an expression default through a module namespace getter `export default ` and the anonymous default function/class have no named binding for the dynamic namespace getter to read, so a namespace used as a dynamic value fenced its `default` property with SC1090. They do have storage: the module registers a `default` global keyed by its default export symbol, which is what a default import reads. The getter keeps that symbol per export and answers the snapshot coerced to `dyn`, so the boxed namespace's `default` matches Node; a missing registration still fences SC1090 and a value with no checked-dynamic form still fences SC1101. Corpus: 8412-module-namespace-untyped gains an `export default 42` module and reads its boxed and typed `default`. Co-Authored-By: Claude Opus 5.5 --- .../lowering/module-namespace-elements.ts | 39 +++++++++++++------ .../8412-module-namespace-untyped/main.ts | 7 ++++ .../mod-default.ts | 4 ++ 3 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 tests/corpus/8412-module-namespace-untyped/mod-default.ts diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index be94aa34a..405d2af5f 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -186,12 +186,16 @@ function moduleNamespaceGetter( if (!source) return null; const symbol = lowerer.checker.getSymbolAtLocation(source); if (!symbol) return null; - const exports: { key: string; declaration: ts.Node | undefined }[] = []; + const exports: { key: string; declaration: ts.Node | undefined; symbol: ts.Symbol }[] = []; for (const exported of lowerer.checker.getExportsOfModule(symbol)) { const target = exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; if (!(target.flags & ts.SymbolFlags.Value)) continue; - exports.push({ key: ts.symbolName(exported), declaration: target.valueDeclaration }); + exports.push({ + key: ts.symbolName(exported), + declaration: target.valueDeclaration, + symbol: target, + }); } exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); const name = `%module.namespace:${ns.type.moduleId}`; @@ -203,24 +207,37 @@ function moduleNamespaceGetter( lowerer.fnStack.push(context); try { const body: IrStmt[] = []; - for (const { key, declaration } of exports) { + for (const { key, declaration, symbol: exportSymbol } of exports) { const node = declaration ?? source; const diagsBefore = lowerer.diags.length; let read: IrStmt[]; try { const identifier = declaration?.name as ts.Node | undefined; - if (!identifier || !ts.isIdentifier(identifier)) { - lowerer.unsupported( - "SC1090", - node, - `reading the unnamed export '${key}' through a module namespace object`, - ); + let value: IrExpr; + if (identifier && ts.isIdentifier(identifier)) { + value = lowerer.lowerExprExpecting(identifier, DYN); + } else { + // `export default ` and the anonymous default + // class/function have no named binding to read. The module's + // pre-registered default global holds the value the export + // statement snapshotted — the same storage a default import + // reads — so the namespace property answers it instead of + // fencing. A default whose type has no checked-dynamic form + // still fails the coercion below and keeps its fence. + const storage = lowerer.globalsBySymbol.get(exportSymbol); + if (!storage) { + lowerer.unsupported( + "SC1090", + node, + `reading the unnamed export '${key}' through a module namespace object`, + ); + } + value = lowerer.coerceToExpected(varRef(storage.id, storage.type, loc), DYN); } - const value = lowerer.lowerExprExpecting(identifier, DYN); if (value.type.kind !== "dyn") { lowerer.unsupported( "SC1101", - identifier, + identifier ?? node, `reading the export '${key}' ('${lowerer.fmt(value.type)}') through a module namespace object`, ); } diff --git a/tests/corpus/8412-module-namespace-untyped/main.ts b/tests/corpus/8412-module-namespace-untyped/main.ts index d569800e6..fcb84a729 100644 --- a/tests/corpus/8412-module-namespace-untyped/main.ts +++ b/tests/corpus/8412-module-namespace-untyped/main.ts @@ -4,6 +4,7 @@ // convert object to primitive value" coercion, plus `Promise` // widening into `Promise` and `unknown`. import * as declared from "./mod.ts"; +import * as expressionDefault from "./mod-default.ts"; function keys(value: unknown): string { return Object.keys(value as any).join(","); @@ -56,6 +57,12 @@ function bump(value: unknown): void { } async function main(): Promise { + // An expression default is snapshotted once by the export statement; + // the boxed namespace's `default` read answers that value, like Node. + const boxedDefault: unknown = expressionDefault; + const dynDefault = boxedDefault as any; + console.log("expression default", dynDefault.default, typeof dynDefault.default); + console.log("expression keys", keys(boxedDefault), expressionDefault.default); describe(declared); bump(declared); const loaded = await import("./mod.ts"); diff --git a/tests/corpus/8412-module-namespace-untyped/mod-default.ts b/tests/corpus/8412-module-namespace-untyped/mod-default.ts new file mode 100644 index 000000000..a0af818a4 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/mod-default.ts @@ -0,0 +1,4 @@ +// An EXPRESSION default (`export default `): Node evaluates the +// expression once, at the export statement, and the namespace object's +// `default` property answers that snapshotted value. +export default 42; From 626e9021a3e9d79fd7b192d766fd8d87e52b07c9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 09:33:10 -0700 Subject: [PATCH 10/12] Record the new import in the module-order baseline The corpus fixture gained `mod-default.ts`, so the recorded module evaluation order for `main.ts` must list it after `mod.ts` and before `main.ts`. Re-recorded from the native frontend; the order-parity sweep (1959 entries) passes against it. Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/8412-module-namespace-untyped/main.ts.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json index 9384505a5..464e68307 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json @@ -1,6 +1,7 @@ { "order": [ "/tests/corpus/8412-module-namespace-untyped/mod.ts", + "/tests/corpus/8412-module-namespace-untyped/mod-default.ts", "/tests/corpus/8412-module-namespace-untyped/main.ts" ], "diags": [] From 857b4ad3e08682907fe2e868f129cef1aa2c2367 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 12:38:39 -0500 Subject: [PATCH 11/12] Read typed-array constructors, NaN and Infinity in shorthand properties A shorthand property's name carries the property's symbol, so the identifier lowering's typed-array and NaN/Infinity cases, which checked that symbol's provenance, did not match `{ Uint8Array }` or `{ NaN }`. A JavaScript source stored the opaque identity token there, and TypeScript stored a runtime fence; the long form `{ Uint8Array: Uint8Array }` read the constructor. Both cases now resolve the name as the other builtin cases do (isStdlibGlobal), which reads the shorthand's value symbol. In JavaScript sources, `globalThis.NaN` and `globalThis.Infinity`, and destructuring them from the global object, read the identity token too. They now read the numbers. --- .../lowering/expressions/identifiers.ts | 5 +- .../src/frontend/lowering/global-values.ts | 3 + .../corpus/shorthand-builtin-values.cjs.json | 6 ++ .../corpus/shorthand-builtin-values.ts.json | 6 ++ tests/corpus/shorthand-builtin-values.cjs | 97 +++++++++++++++++++ tests/corpus/shorthand-builtin-values.ts | 51 ++++++++++ .../shorthand-builtin-values.cjs.json | 1 + .../shorthand-builtin-values.ts.json | 1 + 8 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json create mode 100644 tests/corpus/shorthand-builtin-values.cjs create mode 100644 tests/corpus/shorthand-builtin-values.ts create mode 100644 tests/harness/corpus-costs/shorthand-builtin-values.cjs.json create mode 100644 tests/harness/corpus-costs/shorthand-builtin-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 7ef9b9b0e..c73217ef8 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -700,8 +700,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // String(NaN) is "NaN" in the number formatter; `NaN !== NaN` holds // because f64 compares are IEEE compares). if (expr.text === "Infinity" || expr.text === "NaN") { - const sym = lowerer.checker.getSymbolAtLocation(expr); - if (lowerer.isStdlibSymbol(sym)) { + if (lowerer.isStdlibGlobal(expr, expr.text)) { return { kind: "numLit", value: expr.text === "NaN" ? NaN : Infinity, type: F64, loc }; } } @@ -729,7 +728,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: return { kind: "libCall", fn: "dyn.arrayConstructor", args: [], type: DYN, loc }; if ( Object.values(BYTES_ELEMENT_NAME).includes(expr.text) && - lowerer.isStdlibSymbol(lowerer.checker.getSymbolAtLocation(expr)) + lowerer.isStdlibGlobal(expr, expr.text) ) { return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 21593203d..91f705fef 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -16,6 +16,7 @@ import * as ts from "../ts7/adapter.js"; import { BYTES_ELEMENT_NAME, DYN, + F64, STRING, UNDEFINED_T, type IrExpr, @@ -133,6 +134,8 @@ export function jsGlobalValueByName(lowerer: Lowerer, name: string, loc: SrcLoc) /** jsGlobalValueByName for the globals other than the interned builtin * functions and constructors (builtinGlobalValue). */ export function jsSpecialGlobalValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { + if (name === "NaN" || name === "Infinity") + return { kind: "numLit", value: name === "NaN" ? NaN : Infinity, type: F64, loc }; if (name === "String" || name === "Number" || name === "Boolean" || name === "BigInt") return primitiveCtorClosure(lowerer, name, loc, true); if (name === "ArrayBuffer") diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json new file mode 100644 index 000000000..1b3fb5b25 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shorthand-builtin-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json new file mode 100644 index 000000000..1f444c73d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shorthand-builtin-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/shorthand-builtin-values.cjs b/tests/corpus/shorthand-builtin-values.cjs new file mode 100644 index 000000000..eeee0ff4e --- /dev/null +++ b/tests/corpus/shorthand-builtin-values.cjs @@ -0,0 +1,97 @@ +// A builtin global in a shorthand property (`{ Uint8Array }`) holds the +// value the long form (`{ Uint8Array: Uint8Array }`) and a plain read hold. + +const short = { + Uint8Array, + Int8Array, + Uint8ClampedArray, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + NaN, + Infinity, + String, + Number, + Boolean, + BigInt, + ArrayBuffer, + Array, + Map, + JSON, + Math, + encodeURIComponent, + decodeURI, + crypto, + Buffer, + fetch, + console, + setTimeout, +}; +const long = { + Uint8Array: Uint8Array, + Int8Array: Int8Array, + Uint8ClampedArray: Uint8ClampedArray, + Uint16Array: Uint16Array, + Int16Array: Int16Array, + Uint32Array: Uint32Array, + Int32Array: Int32Array, + Float32Array: Float32Array, + Float64Array: Float64Array, + NaN: NaN, + Infinity: Infinity, + String: String, + Number: Number, + Boolean: Boolean, + BigInt: BigInt, + ArrayBuffer: ArrayBuffer, + Array: Array, + Map: Map, + JSON: JSON, + Math: Math, + encodeURIComponent: encodeURIComponent, + decodeURI: decodeURI, + crypto: crypto, + Buffer: Buffer, + fetch: fetch, + console: console, + setTimeout: setTimeout, +}; + +for (const key of Object.keys(long)) { + const same = Number.isNaN(long[key]) ? Number.isNaN(short[key]) : short[key] === long[key]; + console.log(key, same, typeof short[key]); +} +console.log(short.Uint8Array === Uint8Array, short.Float64Array === Float64Array); +console.log(Object.keys(short).length, Object.keys(short)[0], Object.keys(short)[9]); + +// The typed-array constructors construct and keep their names. +const bytes = new short.Uint8Array([1, 2, 300]); +console.log(bytes, bytes instanceof Uint8Array, short.Uint8Array.name, short.Int32Array.BYTES_PER_ELEMENT); +console.log(new short.Float64Array(2).length, short.Int16Array.from([1, -2])); +console.log(short.NaN !== short.NaN, short.Infinity > 1e308, -short.Infinity); + +// Shorthand destructuring from the global object reads the same values. +function fromGlobal() { + const { Uint8Array, Float32Array, NaN, Infinity, Buffer } = globalThis; + return [Uint8Array === long.Uint8Array, Float32Array === long.Float32Array, Number.isNaN(NaN), Infinity, Buffer === long.Buffer]; +} +console.log(fromGlobal()); + +// A local binding with a builtin's name is the local. +function shadowed() { + const Uint8Array = "local"; + const NaN = 5; + return { Uint8Array, NaN }; +} +console.log(shadowed()); + +// The global object's NaN and Infinity are numbers, read or destructured. +function numbersFromGlobal() { + const { NaN: notANumber, Infinity: infinite } = globalThis; + return [typeof notANumber, notANumber !== notANumber, infinite, infinite === Infinity]; +} +console.log(typeof globalThis.NaN, globalThis.NaN !== globalThis.NaN, globalThis.Infinity, -globalThis.Infinity); +console.log(numbersFromGlobal()); diff --git a/tests/corpus/shorthand-builtin-values.ts b/tests/corpus/shorthand-builtin-values.ts new file mode 100644 index 000000000..ad943b56e --- /dev/null +++ b/tests/corpus/shorthand-builtin-values.ts @@ -0,0 +1,51 @@ +// A builtin global in a shorthand property (`{ Uint8Array }`) holds the +// value the long form (`{ Uint8Array: Uint8Array }`) holds. + +const short: Record = { + Uint8Array, + Int8Array, + Uint8ClampedArray, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + NaN, + Infinity, + String, + ArrayBuffer, + encodeURIComponent, +}; +const long: Record = { + Uint8Array: Uint8Array, + Int8Array: Int8Array, + Uint8ClampedArray: Uint8ClampedArray, + Uint16Array: Uint16Array, + Int16Array: Int16Array, + Uint32Array: Uint32Array, + Int32Array: Int32Array, + Float32Array: Float32Array, + Float64Array: Float64Array, + NaN: NaN, + Infinity: Infinity, + String: String, + ArrayBuffer: ArrayBuffer, + encodeURIComponent: encodeURIComponent, +}; + +for (const key of Object.keys(long)) { + const value = long[key]; + const same = + typeof value === "number" && Number.isNaN(value) + ? Number.isNaN(short[key]) + : short[key] === value; + console.log(key, same, typeof short[key]); +} + +const numbers = { NaN, Infinity }; +console.log(numbers.NaN !== numbers.NaN, numbers.Infinity > 1e308, -numbers.Infinity); + +const ctors = { Uint8Array, Float64Array }; +const bytes = new ctors.Uint8Array([1, 2, 300]); +console.log(bytes, ctors.Uint8Array.name, new ctors.Float64Array(3).length); diff --git a/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json b/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json new file mode 100644 index 000000000..74a3101e8 --- /dev/null +++ b/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 4000 } diff --git a/tests/harness/corpus-costs/shorthand-builtin-values.ts.json b/tests/harness/corpus-costs/shorthand-builtin-values.ts.json new file mode 100644 index 000000000..f71f3eb98 --- /dev/null +++ b/tests/harness/corpus-costs/shorthand-builtin-values.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 2400 } From 577f086658c8e2b8e2d47dd1c0804c5ba9ed989b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 12:49:21 -0500 Subject: [PATCH 12/12] Space the timer delays in two order-sensitive tests and raise the static-spatial timeout mock-timers' "named and namespace imports keep the loop's timers" and 6811-node-island-uncaught-listener check the order in which real timers fire, with delays 1 to 5 ms apart that are scheduled one after another. On a busy runner more time than that can pass between scheduling two of them, and the order flips in either lane: the sanitized node:test run saw Node's reference print 'named promise' first, and the sanitized llvm-differential run saw 'island timer 1' before 'native timer after'. The delays are now 100 ms apart in mock-timers (100, 200, 300) and the island's timers (150, 250) start 100 ms after the native ones (10, 50). Node prints the same output as before. static-spatial bundles three.js and builds it with the sanitizer, which passed the default 300 s test timeout on a busy runner. Its cases take three.test.ts's 1200 s timeout. --- .../corpus/6811-node-island-uncaught-listener/island.cjs | 4 ++-- tests/corpus/6811-node-island-uncaught-listener/main.mjs | 6 ++++-- tests/fixtures/node-test/cases/mock-timers.ts | 9 ++++++--- tests/harness/static-spatial.test.ts | 3 +++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/corpus/6811-node-island-uncaught-listener/island.cjs b/tests/corpus/6811-node-island-uncaught-listener/island.cjs index 650c7f3f3..ae988f8c0 100644 --- a/tests/corpus/6811-node-island-uncaught-listener/island.cjs +++ b/tests/corpus/6811-node-island-uncaught-listener/island.cjs @@ -1,4 +1,4 @@ exports.timers = () => { - setTimeout(() => console.log("island timer 1"), 15); - setTimeout(() => console.log("island timer 2"), 40); + setTimeout(() => console.log("island timer 1"), 150); + setTimeout(() => console.log("island timer 2"), 250); }; diff --git a/tests/corpus/6811-node-island-uncaught-listener/main.mjs b/tests/corpus/6811-node-island-uncaught-listener/main.mjs index 9195e7baa..05397c2e4 100644 --- a/tests/corpus/6811-node-island-uncaught-listener/main.mjs +++ b/tests/corpus/6811-node-island-uncaught-listener/main.mjs @@ -7,12 +7,14 @@ import { callSync } from "@scriptc/node"; process.on("uncaughtException", (error) => { console.log("caught", error.message); }); +// The island's timers (150 and 250 ms) are scheduled before these (10 and +// 50 ms), with gaps wide enough that a slow run keeps the delay order. callSync("./island.cjs", "timers"); setTimeout(() => { console.log("native timer throws"); throw new Error("native boom"); -}, 5); -setTimeout(() => console.log("native timer after"), 10); +}, 10); +setTimeout(() => console.log("native timer after"), 50); setImmediate(() => { queueMicrotask(() => { throw new Error("from a microtask"); diff --git a/tests/fixtures/node-test/cases/mock-timers.ts b/tests/fixtures/node-test/cases/mock-timers.ts index 1e5e5883f..c31b16aa1 100644 --- a/tests/fixtures/node-test/cases/mock-timers.ts +++ b/tests/fixtures/node-test/cases/mock-timers.ts @@ -271,21 +271,24 @@ describe("imports", () => { it("named and namespace imports keep the loop's timers", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const log: string[] = []; + // The three timers are real and scheduled one after another, so their + // delays are 100 ms apart: a slow run (a sanitized build on a busy + // machine) still sees them fire in delay order. await Promise.all([ - sleep(5).then(() => { + sleep(300).then(() => { log.push("named promise"); }), new Promise((resolve) => namedSetTimeout(() => { log.push("named"); resolve(); - }, 2), + }, 100), ), new Promise((resolve) => timersNamespace.setTimeout(() => { log.push("namespace"); resolve(); - }, 3), + }, 200), ), ]); t.mock.timers.tick(10); diff --git a/tests/harness/static-spatial.test.ts b/tests/harness/static-spatial.test.ts index b0f40ee4e..e9bb23b1c 100644 --- a/tests/harness/static-spatial.test.ts +++ b/tests/harness/static-spatial.test.ts @@ -29,6 +29,9 @@ const cases = [ ].flatMap((fixture) => ["native", "wasm32-wasi"].map((target) => ({ fixture, target }))); test.for(cases)( "published $fixture runs statically on $target", + // Bundling three.js and building it with the sanitizer can pass the + // default 300 s on a busy runner, as in three.test.ts. + { timeout: 1_200_000 }, async ({ fixture, target }, context) => { if (target === "wasm32-wasi" && !hasZig) context.skip(); const dir = await mkdtemp("/tmp/scriptc-spatial-");