diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 39756418e..dc2810c11 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 e3e7e3797..aa2163476 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -306,6 +306,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", @@ -1060,6 +1061,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", "nodeIsland.copyArrays": "scr_node_island_copy_arrays", "nodeIsland.awaitCopy": "scr_node_island_await_copy", "dyn.keySet": "scr_dyn_key_set", 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 439c528bb..8464f06d5 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/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index 6461cf7ed..1e35903cd 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -116,6 +116,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", @@ -154,9 +156,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>; diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 6ee5ae159..ced3de029 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -348,6 +348,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/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index f9fa04211..a1a322e48 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/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/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 9804aebdf..df7c06def 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -16,6 +16,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"; @@ -3120,6 +3125,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)); // A module that reads builtin globals through the global object by // computed names installs the program's lookup before its body runs. const computedGlobalRead = computedGlobalReadOf(lowerer, sf); @@ -3381,6 +3390,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 430bcb8d1..e48d482de 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -336,6 +336,16 @@ function islandLoadPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr { return literal; } +/** The path literal of a module the program shares with its islands + * (lower-shared-modules.ts), named by its path in the shipped tree like a + * load's. */ +export function islandSharedPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr { + const literal: IrExpr & { kind: "strLit" } = { kind: "strLit", value: path, type: STRING, loc }; + lowerer.nodeIslandModules.addProvided(path); + lowerer.nodeIslandLoads.push({ literal, path }); + return literal; +} + function loadIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr { return { kind: "libCall", @@ -680,8 +690,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; @@ -691,8 +720,7 @@ function withClassMethods( for (const m of c.def.methods ?? []) if (!names.includes(m) && islandMethodName(m)) 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); @@ -721,13 +749,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..78d970d21 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -0,0 +1,602 @@ +/* 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 { requireModuleSpecifiers, scanModuleSpecifiers } from "../../node-islands/modules.js"; +import { readProjectConfig } from "../../project-config.js"; +import { + sharedClassExtendedDiag, + sharedModuleDuplicatedDiag, +} from "../../diagnostics/diagnostic.js"; +import { + classConstructHelper, + classMethodsBuilder, + islandSharedPath, +} from "./lower-node-island.js"; +import { dynUndefinedExpr } from "../../ir/build.js"; +import { funcTypeFromParamShapes } from "./call-signatures.js"; +import { stripTypeWrappers } from "./typed-arguments.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; + +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. */ +/** Whether evaluating a module-level initializer runs no code: literals, + * names, functions and classes it creates, `require()` of a module (which + * is judged on its own), and object, array and operator expressions over + * those. */ +function inertInitializer(node: ts.Expression): boolean { + const e = stripTypeWrappers(node); + if ( + ts.isIdentifier(e) || + ts.isLiteralExpression(e) || + ts.isNoSubstitutionTemplateLiteral(e) || + ts.isArrowFunction(e) || + ts.isFunctionExpression(e) || + e.kind === ts.SyntaxKind.TrueKeyword || + e.kind === ts.SyntaxKind.FalseKeyword || + e.kind === ts.SyntaxKind.NullKeyword + ) + return true; + if (ts.isClassExpression(e)) + return !e.members.nodes.some( + (m) => + ts.isClassStaticBlockDeclaration(m) || + (ts.isPropertyDeclaration(m) && + m.initializer !== undefined && + !inertInitializer(m.initializer)), + ); + if (ts.isParenthesizedExpression(e)) return inertInitializer(e.expression); + if (ts.isPrefixUnaryExpression(e)) + return ( + e.operator !== ts.SyntaxKind.PlusPlusToken && + e.operator !== ts.SyntaxKind.MinusMinusToken && + inertInitializer(e.operand) + ); + if (ts.isBinaryExpression(e)) + return ( + !ts.isAssignmentOperator(e.operatorToken.kind) && + e.operatorToken.kind !== ts.SyntaxKind.CommaToken && + inertInitializer(e.left) && + inertInitializer(e.right) + ); + if (ts.isConditionalExpression(e)) + return ( + inertInitializer(e.condition) && inertInitializer(e.whenTrue) && inertInitializer(e.whenFalse) + ); + if (ts.isArrayLiteralExpression(e)) + return e.elements.nodes.every((el) => !ts.isSpreadElement(el) && inertInitializer(el)); + if (ts.isObjectLiteralExpression(e)) + return e.properties.nodes.every( + (p) => + ts.isShorthandPropertyAssignment(p) || + ts.isMethodDeclaration(p) || + (ts.isPropertyAssignment(p) && + !ts.isComputedPropertyName(p.name) && + inertInitializer(p.initializer)), + ); + if (ts.isCallExpression(e)) + return ( + ts.isIdentifier(e.expression) && + e.expression.text === "require" && + e.arguments.nodes.length === 1 && + ts.isStringLiteralLike(e.arguments.nodes[0]!) + ); + return false; +} + +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) { + // A copy would run the initializer a second time. + if (d.initializer !== undefined && !inertInitializer(d.initializer)) + return "its variable initializers run code"; + 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. A + // target the program compiles itself is loaded in Node as well: shared. + const queue: string[] = []; + const candidates = new Set(); + for (const sf of lowerer.moduleOrder) { + const text = sf.text; + if (!text.includes("requireModule")) continue; + const from = real(sf.fileName); + for (const spec of requireModuleSpecifiers(text)) { + try { + const target = real(resolveFrom(from, spec)); + const own = compiled.get(target); + if (own !== undefined) candidates.add(own); + else queue.push(target); + } catch { + /* lowering reports it */ + } + } + } + for (const island of lowerer.nodeIslands?.packages.values() ?? []) + packageJsFiles(island.dir, queue); + if (queue.length === 0 && candidates.size === 0) return plans; + const seen = 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; + } + // Specifiers in code only: a comment or a string that looks like an + // import names no module. + for (const spec of scanModuleSpecifiers(text).specifiers) { + for (const target of resolveAll(file, spec)) { + const sf = compiled.get(target); + if (sf !== undefined) { + candidates.add(sf); + const base = extendedImport(text, spec); + 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; + // Before island loads are lowered (the plans are computed in the first + // module's header): the module tree ships these as placeholders. + for (const plan of plans) lowerer.nodeIslandModules.addProvided(plan.path); + 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: [ + islandSharedPath(lowerer, plan.path, 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 475fab7e7..bf9775049 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 { @@ -201,6 +205,7 @@ import { captureContextBinding, declareContextLocal, declareContextThis, + newFnCtx, type FnCtx, } from "./function-context.js"; import type { FrontendServices } from "../services.js"; @@ -1473,7 +1478,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 @@ -3636,6 +3644,11 @@ export class Lowerer { }[] = []; /** Each island module load's path literal, set to its tree path when lowering ends. */ readonly nodeIslandLoads: { literal: IrExpr & { kind: "strLit" }; path: string }[] = []; + /** 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. */ @@ -4115,6 +4128,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. @@ -4390,6 +4407,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", @@ -5231,6 +5253,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); } @@ -11985,6 +12080,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. @@ -12132,6 +12242,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..405d2af5f 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,28 +174,28 @@ 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); 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}`; @@ -206,24 +207,37 @@ export function moduleNamespaceValue( 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`, ); } @@ -263,6 +277,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 +302,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/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/src/index.ts b/packages/compiler/src/index.ts index 7f9566425..e46e69434 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -702,6 +702,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-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 050699bd4..98b2687f2 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -23,6 +23,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "nodeIsland.construct", "nodeIsland.instanceof", "nodeIsland.classMethods", + "nodeIsland.classMethodsOf", "nodeIsland.copyArrays", // Node's argument, cipher-name, IV, tag-length and key-length errors. "crypto.cipherNew", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index bfb39cf16..958f35011 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -207,6 +207,14 @@ function buildLibFnSignatures() { /** 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 }, /** Island arrays of copied values as copies (a result, or an awaited * promise's value). */ "nodeIsland.copyArrays": { argTypes: [DYN], result: DYN }, @@ -3604,6 +3612,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/ir.ts b/packages/compiler/src/ir/ir.ts index ce220275b..7200da6dd 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1037,7 +1037,9 @@ export function isRefCounted(t: IrType): boolean { * Only the relative paths enter an executable. */ export interface IrNodeIslandModules { root: string; - files: { path: string; size: number; sha256: string }[]; + /** `placeholder`: a program module the islands share, shipped as + * providedModulePlaceholder (node-islands/modules.ts), not its source. */ + files: { path: string; size: number; sha256: string; placeholder?: true }[]; links: { path: string; target: string }[]; /** Where the executable finds the tree: absent when it carries the tree * itself, else a `$ORIGIN/` path (`--libnode=beside`). */ diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 58fce50b6..ed97ca935 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1197,6 +1197,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: @@ -1296,6 +1298,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/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index 0210ab51b..8e44652a1 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -41,7 +41,7 @@ import { 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/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index 6207f5809..2c0d8ec73 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -6,6 +6,7 @@ import { NodeIslandModuleCollector, nodeIslandManifest, readNodeIslandModules, + requireModuleSpecifiers, scanModuleSpecifiers, } from "./modules.js"; @@ -259,3 +260,53 @@ describe("the collected module tree", () => { expect(readNodeIslandModules(tree)).toBe(`${join(root, "m.cjs")} changed during the build`); }); }); + +describe("modules the program provides", () => { + test("a shared program module ships as a placeholder, not its source", () => { + const source = "// internal pricing rules\nexport const margin = 0.37;\n"; + const root = project({ + "src/main.ts": "", + "src/pricing.ts": source, + "src/plugin.mjs": "export const x = 1;", + }); + const collector = new NodeIslandModuleCollector(); + expect(collector.add(join(root, "src/main.ts"), "./plugin.mjs")).toBeNull(); + collector.addProvided(join(root, "src/pricing.ts")); + const tree = collector.tree()!; + const entry = tree.files.find((f) => f.path.endsWith("pricing.ts"))!; + expect(entry.placeholder).toBe(true); + const data = readNodeIslandModules(tree); + if (typeof data === "string") throw new Error(data); + const shipped = new TextDecoder().decode(data); + expect(shipped).not.toContain("internal pricing rules"); + expect(shipped).not.toContain("margin"); + expect(shipped).toContain("export const x = 1;"); + }); + + test("island code that imports a provided module ships neither its source nor its imports", () => { + const root = project({ + "src/main.ts": "", + "src/plugin.mjs": 'import { y } from "./shared.mjs";\nexport const z = y;', + "src/shared.mjs": 'import { secret } from "./internal.mjs";\nexport const y = secret;', + "src/internal.mjs": "export const secret = 42;", + }); + const collector = new NodeIslandModuleCollector(); + collector.addProvided(join(root, "src/shared.mjs")); + expect(collector.add(join(root, "src/main.ts"), "./plugin.mjs")).toBeNull(); + const files = collector.tree()!.files; + expect(files.find((f) => f.path.endsWith("shared.mjs"))!.placeholder).toBe(true); + expect(files.some((f) => f.path.endsWith("internal.mjs"))).toBe(false); + }); + + test("requireModule specifiers come from code, not comments or strings", () => { + expect( + requireModuleSpecifiers(` + // requireModule("./commented.mjs") + /* requireModule("./block.mjs") */ + const s = 'requireModule("./in-string.mjs")'; + const t = \`requireModule("./in-template.mjs")\`; + const p = requireModule("./plugin.mjs"); + `), + ).toEqual(["./plugin.mjs"]); + }); +}); diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index 4ed57a038..7fc5c46d7 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -172,8 +172,10 @@ export class NodeIslandModuleCollector { private readonly traced = new Set(); /** Real paths of the calling modules. */ private readonly callers = new Set(); - /** Real paths of the files loads name (addLoaded). */ + /** Real paths of the files loads name (addLoaded, addProvided). */ private readonly loaded = new Set(); + /** Program modules whose source the program provides (addProvided). */ + private readonly provided = new Set(); /** Collects the module `specifier` names from `caller` (a real path). * Returns null, or the reason the build cannot ship it. */ @@ -195,6 +197,23 @@ export class NodeIslandModuleCollector { return this.addModule(this.placed(file)); } + /** Collects a module whose source the program provides (a shared module, + * runtime scr_node_island_hooks.cjs): Node still resolves it to its file, + * so its package is in the tree, or, for a program module, a placeholder + * at its path (providedModulePlaceholder): the program's source stays out + * of the executable. Island code that imports it loads the program's + * module too, so it is not traced either. */ + addProvided(file: string): void { + this.loaded.add(file); + const at = packageDirOf(file); + if (at !== null) this.addPackage(at.dir); + else { + const placed = this.placed(file); + this.files.add(placed); + this.provided.add(placed); + } + } + /** The collected tree, or null when no call loads a file. */ tree(): NodeIslandModuleTree | null { if (this.files.size === 0 && this.links.size === 0) return null; @@ -213,13 +232,15 @@ export class NodeIslandModuleCollector { if (outer >= 0) root = parts.slice(0, outer).join(sep) || sep; const files: NodeIslandModuleFile[] = []; for (const file of [...this.files].sort()) { + const path = portable(relative(root, file)); + if (this.provided.has(file)) { + const bytes = providedModulePlaceholder(path); + files.push({ path, size: bytes.length, sha256: sha256(bytes), placeholder: true }); + continue; + } const bytes = trackedReadBytes(file); if (bytes === null) continue; - files.push({ - path: portable(relative(root, file)), - size: bytes.length, - sha256: sha256(bytes), - }); + files.push({ path, size: bytes.length, sha256: sha256(bytes) }); } const links: NodeIslandModuleLink[] = [...this.links] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) @@ -295,6 +316,9 @@ export class NodeIslandModuleCollector { if (this.traced.has(file)) return null; this.traced.add(file); this.files.add(file); + // The program provides its source: island code never runs this file, + // so what it imports is not shipped for it. + if (this.provided.has(file)) return null; const scope = packageScope(dirname(file)); if (scope !== null) this.files.add(scope); if (!TRACED.test(file)) return null; @@ -566,6 +590,27 @@ export interface ModuleSpecifierScan { computed: { at: number; form: string }[]; } +/** The string-literal arguments of `requireModule(...)` calls in source + * code, comments and template text excluded. */ +export function requireModuleSpecifiers(text: string): string[] { + const tokens = moduleTokens(text); + const out: string[] = []; + for (let k = 0; k + 3 < tokens.length; k++) { + const [name, open, arg, close] = [tokens[k]!, tokens[k + 1]!, tokens[k + 2]!, tokens[k + 3]!]; + if ( + name.kind === "name" && + name.value === "requireModule" && + open.kind === "punct" && + open.value === "(" && + arg.kind === "string" && + close.kind === "punct" && + close.value === ")" + ) + out.push(arg.value); + } + return out; +} + export function scanModuleSpecifiers(text: string): ModuleSpecifierScan { const tokens = moduleTokens(text); const specifiers: string[] = []; @@ -678,6 +723,16 @@ export function scanModuleSpecifiers(text: string): ModuleSpecifierScan { /* ── shipping the tree ────────────────────────────────────────────────── */ +/** What the tree ships at the path of a program module the islands share: + * Node resolves the module to this file and the program's loader hooks + * answer its source (runtime scr_node_island_hooks.cjs), so the file holds + * none of the program's source. Evaluating it throws. */ +export function providedModulePlaceholder(path: string): Uint8Array { + return new TextEncoder().encode( + `throw new Error(${JSON.stringify(`${path} is provided by the compiled program`)});\n`, + ); +} + /** The manifest the executable records (scr_node_island.c reads it): * `{"files":[[path,size,sha256],...],"links":[[path,target],...]}`. Its * SHA-256 names the tree. */ @@ -696,11 +751,13 @@ export function readNodeIslandModules(modules: IrNodeIslandModules): Uint8Array for (const file of modules.files) { const path = join(modules.root, ...file.path.split("/")); let bytes: Uint8Array; - try { - bytes = new Uint8Array(readFileSync(path)); - } catch { - return `${path} was removed during the build`; - } + if (file.placeholder === true) bytes = providedModulePlaceholder(file.path); + else + try { + bytes = new Uint8Array(readFileSync(path)); + } catch { + return `${path} was removed during the build`; + } if (bytes.length !== file.size || sha256(bytes) !== file.sha256) return `${path} changed during the build`; parts.push(bytes); diff --git a/packages/compiler/src/node-islands/snapshot.ts b/packages/compiler/src/node-islands/snapshot.ts index 8c2c25562..54352743a 100644 --- a/packages/compiler/src/node-islands/snapshot.ts +++ b/packages/compiler/src/node-islands/snapshot.ts @@ -312,7 +312,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..cd8d0b677 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6850-node-island-shared-singleton/state.mjs", + "/tests/corpus/6850-node-island-shared-singleton/tally.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/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..464e68307 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1,8 @@ +{ + "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": [] +} 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/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/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 61d822309..73b381bdd 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)); @@ -267,6 +271,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); SetMethod(context, native, "isExport", IsExport); global->Set(context, Utf8(isolate, "__scriptcNodeIslandHost"), native).Check(); TryCatch try_catch(isolate); @@ -300,7 +307,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); @@ -638,7 +646,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; @@ -909,6 +917,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; @@ -920,6 +936,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()); @@ -1103,6 +1128,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(); @@ -1563,6 +1596,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, @@ -1599,6 +1682,7 @@ const ScrNodeBridge kBridge = { DropHandle, LiveHandles, ExportSettle, + SharedPush, }; } // namespace diff --git a/packages/libnode-common/smoke/smoke.c b/packages/libnode-common/smoke/smoke.c index ea3a2e41f..21b35f0ed 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) { diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 70d0dae16..e5f41472b 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -93,6 +93,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_json.c b/packages/runtime/src/scr_json.c index 76bce14a4..5363a64c7 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2652,6 +2652,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_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/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 4c35b86d8..773c42d04 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -987,6 +987,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(); @@ -1131,6 +1134,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; } @@ -1174,6 +1178,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, }; typedef struct { diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 693857c6b..823796491 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -140,7 +140,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 a24cb6428..2aaac7ad6 100644 --- a/packages/runtime/src/scr_node_island_export.c +++ b/packages/runtime/src/scr_node_island_export.c @@ -130,6 +130,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 scr_promise_identity_key(v->v.promise); @@ -143,7 +145,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: @@ -301,6 +303,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); @@ -308,6 +311,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); @@ -317,13 +322,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); @@ -340,6 +357,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; @@ -368,11 +386,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. An own property the * island wrote under the method's name (kept in the capsule's materialized * view) hides the method, as an own property hides a prototype's in Node. */ @@ -401,8 +476,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); @@ -432,6 +510,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 */ @@ -447,6 +526,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; @@ -481,7 +565,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; @@ -509,6 +595,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 { @@ -579,8 +678,252 @@ 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); + /* Each module's file as Node resolves it: in the shipped module tree. */ + char **paths = calloc(ni_nshared > 0 ? ni_nshared : 1, sizeof *paths); + if (paths == NULL) ni_export_oom(); + size_t len = 2; + for (uint32_t i = 0; i < ni_nshared; i++) { + paths[i] = scr_ni_module_path(ni_shared[i].path); + len += strlen(paths[i]) * 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 (const char *p = paths[i]; *p != '\0'; p++) { + char c = *p; + if (c == '"' || c == '\\') buf[at++] = '\\'; + buf[at++] = c; + } + free(paths[i]); + at += (size_t)sprintf(buf + at, "\",\"exports\":"); + memcpy(buf + at, m->exports->data, m->exports->len); + at += m->exports->len; + buf[at++] = '}'; + } + buf[at++] = ']'; + free(paths); + 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++) { @@ -595,8 +938,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 b9f8c7178..78b016355 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; } @@ -1133,6 +1137,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()); @@ -1216,6 +1234,21 @@ static bool nh_op_set(void *h, const char *key, size_t key_len, const ScrDyn *va return true; /* handled; a throw stays pending */ } +/* A member that reads back as one of the program's own functions (a + * module the program shares with its islands gives them its native + * exports): called natively, with the island object as the receiver. */ +static bool nh_is_native_function(const ScrDyn *fn) { + return !scr_ni_is_handle(fn) && scr_dyn_is_callable(fn); +} + +static ScrDyn *nh_call_native(ScrDyn *fn, ScrDyn *this_, ScrDyn *const *args, size_t argc, + const char *what) { + scr_dyn_this_push_dyn(this_); + ScrDyn *result = scr_dyn_call(fn, args, argc, what); + scr_dyn_this_pop(); + return result; +} + /* obj.m(...) from dynamic code: the member read, then the call with obj * as the receiver; a member that is not a function throws Node's TypeError * with the call site's text (`what`), as V8 words it. */ @@ -1224,6 +1257,11 @@ static ScrDyn *nh_op_invoke(void *h, ScrDyn *self, const char *method, ScrDyn *c (void)h; ScrDyn *fn = scr_ni_get_str(self, method, strlen(method)); if (fn == NULL) return NULL; + if (nh_is_native_function(fn)) { + ScrDyn *result = nh_call_native(fn, self, args, argc, what != NULL ? what : method); + scr_dyn_release(fn); + return result; + } if (!scr_ni_is_function(fn)) { scr_dyn_release(fn); char message[512]; @@ -1327,6 +1365,7 @@ ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args) { ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); + if (nh_is_native_function(fn)) return nh_call_native(fn, this_, items, argc, "value"); return scr_ni_call(fn, this_, items, argc); } 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..b587132cf --- /dev/null +++ b/packages/runtime/src/scr_node_island_hooks.cjs @@ -0,0 +1,149 @@ +// ── 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, 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. +// +// 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" || 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 { + 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 b9f148e75..e114f7235 100644 --- a/packages/runtime/src/scr_node_island_host.cjs +++ b/packages/runtime/src/scr_node_island_host.cjs @@ -222,11 +222,24 @@ 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) { // A built-in module's name, with or without the node: prefix. if (isBuiltin(path)) 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) ───────────────────────────────────── @@ -495,4 +508,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 bdcd41892..23209bcda 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 @@ -228,11 +229,24 @@ 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" " // A built-in module's name, with or without the node: prefix.\012" " if (isBuiltin(path)) 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" @@ -501,6 +515,157 @@ 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, 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" + "// 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\" || 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" + " 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/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/corpus/6850-node-island-shared-singleton/main.mjs b/tests/corpus/6850-node-island-shared-singleton/main.mjs new file mode 100644 index 000000000..ec3b76256 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/main.mjs @@ -0,0 +1,46 @@ +// @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, describe, retitle } from "./state.mjs"; +import { bumpTally, tally } from "./tally.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()); + +// A module loaded straight into Node is the program's own instance, and +// its members called as methods are the program's functions. +const direct = requireModule("./state.mjs"); +const { record: directRecord, entries: directEntries } = direct; +directRecord("direct-1"); +console.log("direct load", count, entries(), directEntries() === entries()); +console.log("direct methods", direct.record("direct-2"), direct.record(String(count + 1)), direct.entries()); +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 new file mode 100644 index 000000000..3d73ed191 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs @@ -0,0 +1,41 @@ +// 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, describe } 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(); +} + +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 new file mode 100644 index 000000000..0bbf8989c --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/state.mjs @@ -0,0 +1,31 @@ +// @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 = []; +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; +} + +// 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"; +} diff --git a/tests/corpus/6850-node-island-shared-singleton/tally.mjs b/tests/corpus/6850-node-island-shared-singleton/tally.mjs new file mode 100644 index 000000000..cf6a39af0 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/tally.mjs @@ -0,0 +1,8 @@ +// A program module no island code imports, loaded into Node only through +// requireModule(): it is still the program's one instance. +export let tally = 0; + +export function bumpTally() { + tally++; + return tally; +} 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..060c11671 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/main.mjs @@ -0,0 +1,21 @@ +// @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"; +import { show } from "banner"; + +const { run } = requireModule("./scenario.mjs"); +const log = (s) => fs.writeSync(1, `${s}\n`); +reg.register(new reg.Plugin("owner-a")); +run(log); +log(`owner banner: ${show()}`); +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/banner/index.d.ts b/tests/corpus/6853-node-island-shared-registry/node_modules/banner/index.d.ts new file mode 100644 index 000000000..aeff6941f --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/banner/index.d.ts @@ -0,0 +1,2 @@ +export declare const label: string; +export declare function show(): string; diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/banner/index.js b/tests/corpus/6853-node-island-shared-registry/node_modules/banner/index.js new file mode 100644 index 000000000..599d9b538 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/banner/index.js @@ -0,0 +1,6 @@ +// Looks pure (a const and a function), but its initializer prints: one +// instance must evaluate it once. +export const label = (console.log("banner loaded"), "B"); +export function show() { + return label; +} diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/banner/package.json b/tests/corpus/6853-node-island-shared-registry/node_modules/banner/package.json new file mode 100644 index 000000000..4c0c8670b --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/banner/package.json @@ -0,0 +1 @@ +{"name": "banner", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts"} 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..6e3be0db7 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/scenario.mjs @@ -0,0 +1,28 @@ +// 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'; +import { show } from 'banner'; +const require = createRequire(import.meta.url); + +export function run(log) { + log(`island banner: ${show()}`); + 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/corpus/8412-module-namespace-untyped/main.ts b/tests/corpus/8412-module-namespace-untyped/main.ts new file mode 100644 index 000000000..fcb84a729 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/main.ts @@ -0,0 +1,77 @@ +// 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"; +import * as expressionDefault from "./mod-default.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 { + // 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"); + 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-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; 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/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("|")); 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/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/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/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 } 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 } diff --git a/tests/harness/node-island-shared-sources.test.ts b/tests/harness/node-island-shared-sources.test.ts new file mode 100644 index 000000000..bdf091193 --- /dev/null +++ b/tests/harness/node-island-shared-sources.test.ts @@ -0,0 +1,75 @@ +/* Which program modules a program shares with its Node islands, and what + * of them the executable carries (scope-B NI-6). Builds to IR only: no + * libnode is needed. */ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { compile, type ir } from "@scriptc/compiler"; + +// Outside any node_modules directory: a program there would be a package. +const cacheDir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-island-shared-"))); + +const MAIN = `import { requireModule } from "@scriptc/node"; +import { tally } from "./tally.mjs"; +const plugin = requireModule("./plugin.mjs"); +console.log(tally(), plugin.name); +`; +const TALLY = `// tally: internal counting rules +let n = 0; +export function tally() { return ++n; } +`; + +async function irOf(name: string, plugin: string): Promise { + const dir = join(cacheDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "main.mjs"), MAIN); + writeFileSync(join(dir, "tally.mjs"), TALLY); + writeFileSync(join(dir, "plugin.mjs"), plugin); + const result = await compile(join(dir, "main.mjs"), { + outputKind: "ir", + outPath: join(dir, "program.ir.json"), + outDir: dir, + backend: "llvm", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return JSON.parse(readFileSync(result.artifact.path, "utf8")) as ir.IrModule; +} + +function sharedPaths(mod: ir.IrModule): string[] { + const out: string[] = []; + JSON.stringify(mod, (_k, v: unknown) => { + const o = v as { kind?: string; fn?: string; args?: { kind?: string; value?: string }[] }; + if (o !== null && typeof o === "object" && o.kind === "libCall" && o.fn === "nodeIsland.share") + for (const a of o.args ?? []) + if (a.kind === "strLit" && a.value !== undefined) out.push(a.value); + return v; + }); + return out; +} + +afterAll(() => rmSync(cacheDir, { recursive: true, force: true })); + +describe("program modules shared with Node islands", () => { + test("a specifier in a comment of island code shares nothing", async () => { + const mod = await irOf( + "comment", + `// This plugin never imports "./tally.mjs": import { tally } from "./tally.mjs";\nexport const name = "plugin";\n`, + ); + expect(sharedPaths(mod).some((p) => p.endsWith("tally.mjs"))).toBe(false); + }); + + test("a shared module's source stays out of the shipped tree", async () => { + const mod = await irOf( + "import", + `import { tally } from "./tally.mjs";\nexport const name = "plugin:" + tally();\n`, + ); + expect(sharedPaths(mod).some((p) => p.endsWith("tally.mjs"))).toBe(true); + const tree = mod.nodeIsland?.modules; + expect(tree).toBeDefined(); + const entry = tree!.files.find((f) => f.path.endsWith("tally.mjs")); + expect(entry?.placeholder).toBe(true); + expect(tree!.files.map((f) => f.path).sort()).toEqual(["plugin.mjs", "tally.mjs"]); + }); +}); diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 971311d75..ea4d3e6c1 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -308,7 +308,7 @@ describe.skipIf(skip)("Node islands in-process", () => { }); 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`, ); }); 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-");